diff --git a/CHANGELOG.md b/CHANGELOG.md index c804a45..ad3d400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,56 @@ Version bump guide: ## [Unreleased] +## [3.0.0] — 2026-08-22 + +### Removed +- **The View Transition API is gone from DRYL.** Every morph in the library now runs on FLIP instead. The browser API replaced the **live page** with snapshots for the duration of a transition and swapped back to live rendering at the end — measured as a 1718 × 1248 px snapshot of the whole viewport for a change to one card. That swap is visible everywhere at once: text antialiasing shifts, `backdrop-filter` surfaces recomposite, gradients re-raster. It looked like the entire page flickering every time one element moved, and no amount of CSS could switch it off. FLIP does the opposite: measure where things are, let Blazor render, measure again, and animate the difference on the real elements. The page stays live and only what actually moved is animated — verified side by side in the browser: **0 snapshots and 0 calls to the API** afterwards, against 10 pseudo-elements before. + +**Removed API — what to use instead:** + +| Removed | Replacement | +|---|---| +| `IDrylViewTransition` | `IDrylMorph` — same `RunAsync(mutate)` shape and the same `SignalRendered()` contract. `BeginNavigation(TimeSpan)` becomes `BeginNavigationAsync(TimeSpan)`. | +| `DrylViewTransitionStyle` | `DrylMorphStyle`, with the same `Glide` and `DepthGlass` values. | +| `DrylCard.ViewTransitionName`, `DrylCard.ViewTransitionStyle` | Wrap the card in ``. The card is a surface; being a morph target is the hull's job, and this way any content can be one. | +| `DialogOptions.HandoffStyle` typed as `DrylViewTransitionStyle` | Same property, now typed `DrylMorphStyle`. `AnimateHandoff` is unchanged. | +| `window.dryl.viewTransition` | `window.dryl.morph` (`capture` / `play`). Only relevant if you called the bridge yourself. | +| The `::view-transition-*` block in `dryl.css` | Nothing to replace — the movement is created by the engine, not by stylesheet rules. | + +Everything else is a rename you can follow mechanically. If you never touched these names, the only change you will notice is that morphs look better. + +### Changed +- `DrylMorph` — marks its target with `data-dryl-morph` instead of rendering a `view-transition-name`, and its `Style` parameter takes `DrylMorphStyle`. The parameters, their defaults and their meaning are otherwise unchanged. +- `DrylTable` — row reordering runs on FLIP, which is the classic technique for exactly this: each row is measured, the list re-renders, and every row that moved travels to its new position. Rows carry `data-dryl-morph` instead of a per-row `view-transition-name`. +- The dialog handoff, `DrylCanvas` and `DrylCanvasWorkspace` all move over to the engine. Their behaviour is unchanged; their movement no longer freezes the page around them. +- `DepthGlass` is rebuilt on FLIP: the surface passes through translucency and blur while it travels and arrives clear, instead of two snapshots merging through a filter. +- **A morph that changes size now really morphs.** An element that grows — a card opening into a detail view, a dialog handing over to the next step — takes the face it had before the change with it: a clone captured at the start rides the same curve to the new box and fades out while the new content settles in, so the two views read as one object growing rather than one panel replacing another. An element that only *moves* is still only moved, so a table row reorder pays for nothing it does not use. The clones live in a top-layer holder (`.dryl-morph-ghosts`), are `aria-hidden`, take no pointer, are never targets themselves, and are removed the moment they have faded; nothing is created at all under `prefers-reduced-motion`. + +### Fixed +- **A morph read its own measurements wrong and animated nothing.** `play()` divided by `to.w` where the browser's `DOMRect` calls it `to.width`, so every scale factor came out `NaN` and the resulting transform was invalid — the element jumped to its new size instead of travelling there. Both the `Glide` and the `DepthGlass` tier were affected; `DepthGlass` still showed its blur, which made it look like a plain cross-fade. +- **The counter-scale cancelled the morph it was meant to protect.** The element's first child was scaled by the inverse of the move — and on every real morph target that child *is* the visible surface (the card, the panel, the dialog), so the surface stayed exactly its final size for the whole move while only the invisible hull grew around it. The hand-over above replaces it. +- **A sequential dialog handoff stuttered instead of morphing.** Three separate causes, all in the hand-over: the top-layer holder inherited the UA stylesheet's `width: fit-content` and was therefore 0 x 0, which collapsed the dialog clone sized against it to 2px; the dialog's own enter animation was still holding it at `scale(0.96)` when the engine measured it, so every target came out 4% too small and the morph started too large and shrank into place; and the two faces crossed by fading through each other, which let the ground show through both and dimmed the whole panel for a beat. The holder is now the viewport, targets are measured only after any animation already on them has been wound forward to its finished state — so the morph is the single choreography on the element — and the old face holds until the new one is solid. +- **Nested morph targets moved twice.** A dialog names its header, body and footer as targets of their own as well as the dialog around them; each was animated on top of the movement it was already being carried by. A target inside another target is now left to its parent. + + + +## [2.26.0] — 2026-08-22 + +### Fixed +- **The collapsed sidebar rail is legible again.** Every icon in a collapsed `DrylDrawer` was being squeezed to **5px wide** instead of 16 — a row of unrecognisable smears rather than icons. Two things were taking the space: the nav rows kept the horizontal padding of their expanded state (24px of it inside a 29px row), and a classic scrollbar claimed another quarter of a 56px rail, pushing what was left off-centre. On a rail the scrollbar is unusable as a control anyway, so it no longer takes space there — wheel, trackpad and keyboard scrolling are untouched — and the rows give up their side padding to the rail that already provides it. Icons are back at their full size, centred, and the labels stay hidden exactly as before. Nothing changes while the sidebar is expanded. + +### Added +- `DrylRouteTransition` — **a morph across a real route change.** `DrylMorph` (2.25.0) covers a switch that happens on one route; this covers `/planets` → `/planets/42`, a `NavLink`, and the browser's Back button. Mount it once beside `DrylDialogProvider`, put a `DrylMorph` with the same `Name` on both pages, and the object the user pressed travels with them — no router hook, no timing code. It works by starting the transition in a location-changing handler and then getting out of the way: it **never** calls `PreventNavigation`, never cancels and never restarts a navigation, so the history stack and the Back and Forward buttons behave exactly as they do without it. The old snapshot is taken when the handler runs; the transition is held open until a `DrylMorph` on the destination reports its render. The host also reports the new route's first render itself, so a destination carrying no hull completes immediately rather than waiting — and a destination still loading its data morphs onto its placeholder instead of freezing the page it came from. `Timeout` (one second by default) sits underneath as the second net, for a route that never renders at all: when it elapses the navigation completes without a morph, so the component holds a frame, never your application. `ShouldMorph` takes the target URI and excludes individual navigations (a sign-out, a jump to an unrelated part of the app); left unset, every internal navigation morphs. The component renders no markup. +- `IDrylViewTransition.BeginNavigation(TimeSpan)` — the entry point behind it, for a transition that a *coming navigation* completes rather than one the service mutates itself. It ships as a **default interface implementation that does nothing**, so if you implement `IDrylViewTransition` yourself, your code keeps compiling untouched and navigations simply do not morph until you override it. + +## [2.25.0] — 2026-08-22 + +### Changed +- **View transitions settle before they end.** Every morph the library runs — `DrylMorph`, `DrylCard`'s `ViewTransitionName`, the dialog handoff, `DrylTable`'s row reorder, the canvas — brings its incoming snapshot in on `--dur-med` instead of the browser's default full-length cross-fade. The reason is what the browser actually animates: a view-transition group morphs by scaling `width` and `height`, so both snapshots are bitmaps being stretched for the whole `--dur-slow`, and when the transition ends they are swapped for sharp DOM in a single frame. Ending the fade early means that final stretch of movement shows one settled image, so the swap lands on a picture that has already stopped changing instead of on one still cross-fading — which is what read as a stumble at the very end of a morph. The `DepthGlass` tier already worked this way and is unchanged; this brings the default `Glide` tier in line with it. Nothing about the shape, the duration or the easing of the movement changes. + +### Added +- `DrylMorph` — **a transition ID for any content.** Shared-element transitions were available on exactly one component: `DrylCard` took a `ViewTransitionName`, and everything else — a list row, an image, a heading, a plain `div` — had to hand-write the inline `view-transition-name`, with no place to hang the `DepthGlass` tier's transition class or the marker the JS bridge keys on. `DrylMorph` is the generic hull: wrap the same `Name` around the card in an overview and around the heading of the detail view, and the browser morphs position, size and opacity from one to the other instead of cutting between two screens. `Style` picks the tier (`Glide` or `DepthGlass`), `As` chooses the rendered tag so the hull is valid where it sits (`li` in a list, `tr` in a table), and `Active` lets a long overview name only the entry being opened — a duplicate name at snapshot time makes the browser skip the morph silently, so a hundred permanently-named rows is the one shape to avoid. The hull also takes over the half of the timing contract that is easy to forget: it reports every render to `IDrylViewTransition`, so **`SignalRendered()` never has to be written by hand**. Starting the transition stays with you — `IDrylViewTransition.RunAsync(...)` — because the hull cannot see when the rest of your page has finished rendering and will not pretend otherwise. It renders one element and nothing else: no class, no colour, no frost; the morph's duration, easing and merge are the existing `::view-transition-*` rules. Nothing changes for existing code — `DrylCard.ViewTransitionName` behaves exactly as before and now builds its markup through the same shared helper. + ## [2.24.3] — 2026-08-20 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 56283b7..0107929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ repository the evidence is: `dotnet build DRYL.slnx -c Release`, `node scripts/check-spec-coverage.mjs`, `node scripts/check-motion-tokens.mjs`, and both color modes checked by eye. The coverage check exits non-zero until every component has a spec; during -phase C its `x/127 components covered` line is the progress meter, and a rising +phase C its `x/129 components covered` line is the progress meter, and a rising number is the evidence — not a green exit. If a step was skipped, say so. If tests fail, say so with the output. diff --git a/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor b/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor index 286595e..6c805f5 100644 --- a/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor +++ b/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor @@ -2,7 +2,7 @@ @using DRYL.Components.Ai @using DRYL.Components.Canvas @using DRYL.Components.Motion -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @implements IDisposable @* ───────────────────────────────────────────────────────── @@ -15,7 +15,7 @@ * the AI aura that breathes with the run state and flashes on completion * a DrylAiIndicator in the header doubling as a live element counter * the aria-live announcements (building / updated / ready / failed) - * the view-transition morph when a second create_artifact replaces the + * the morph when a second create_artifact replaces the whole artifact — the old tree morphs into the new one instead of snapping * forwarding the measured body width to the run, so the next generation is authored for the space the artifact actually has @@ -93,7 +93,7 @@ // The CanvasSpec instance the DOM currently shows. A different instance arriving through // OnChange means a whole new artifact replaced the old one — the one case that gets a - // view-transition morph (see HandleChange). + // morph (see HandleChange). private CanvasSpec? _renderedSpec; protected override void OnParametersSet() @@ -134,12 +134,12 @@ private void HandleChange() => InvokeAsync(async () => { // The C# state has already moved on, but the DOM still shows the old tree — exactly - // the window document.startViewTransition needs. A brand-new CanvasSpec instance means + // the morph engine needs. A brand-new CanvasSpec instance means // a second create_artifact replaced the artifact wholesale; morph old into new instead // of letting the old tree vanish untransitioned. The very first artifact has no "old" // picture worth morphing, so it keeps the node-by-node reveal choreography. // Except when the swap came from a view switch: DrylCanvasWorkspace owns that morph, and - // nesting two view transitions loses one of the mutations. + // nesting two morphs loses one of the mutations. var switched = Run?.ConsumeSwapMorphSuppression() == true; var swap = !switched && _renderedSpec is not null && !ReferenceEquals(_renderedSpec, Run?.Spec); @@ -147,7 +147,7 @@ if (swap) { - await ViewTransition.RunAsync(() => + await Morph.RunAsync(() => { _renderedSpec = Run?.Spec; StateHasChanged(); diff --git a/code/DRYL.Components.Agents/Tools/DrylUiTools.cs b/code/DRYL.Components.Agents/Tools/DrylUiTools.cs index d850de9..47079aa 100644 --- a/code/DRYL.Components.Agents/Tools/DrylUiTools.cs +++ b/code/DRYL.Components.Agents/Tools/DrylUiTools.cs @@ -61,7 +61,7 @@ private async Task AskChoiceImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var reference = await _dialogs.ShowAsync("Choose", p, dialogOptions); var result = await Await(reference, ct); @@ -85,7 +85,7 @@ private async Task AskMultiChoiceImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var reference = await _dialogs.ShowAsync("Choose", p, dialogOptions); var result = await Await(reference, ct); @@ -103,7 +103,7 @@ private async Task RequestPermissionImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var showTask = _dialogs.ShowConfirmAsync("Permission required", message, "Allow", "Deny", dialogOptions); @@ -128,7 +128,7 @@ private async Task AskTextImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var reference = await _dialogs.ShowAsync("Question", p, dialogOptions); var result = await Await(reference, ct); diff --git a/code/DRYL.Components/Components/AI/DrylCanvas.razor b/code/DRYL.Components/Components/AI/DrylCanvas.razor index a83d010..fc85f9e 100644 --- a/code/DRYL.Components/Components/AI/DrylCanvas.razor +++ b/code/DRYL.Components/Components/AI/DrylCanvas.razor @@ -5,7 +5,7 @@ @using Microsoft.Extensions.Logging @inject IJSRuntime JS @inject IServiceProvider Services -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @implements IAsyncDisposable @* ───────────────────────────────────────────────────────── @@ -30,7 +30,7 @@ ───────────────────────────────────────────────────────── *@ -
@Overlay @@ -211,11 +211,11 @@ private string? _selectionAnnouncement; private bool _reorderAttached; - // Per-instance view-transition-name. Must be document-globally unique while in the DOM: - // a duplicate name voids the entire transition, so two canvases on one page each get + // Per-instance morph name. Must be document-globally unique while in the DOM: a + // duplicate name would move the wrong canvas, so two canvases on one page each get // their own counter value. - private static int _vtSeq; - private readonly string _vtName = FormattableString.Invariant($"dryl-canvas-{Interlocked.Increment(ref _vtSeq)}"); + private static int _morphSeq; + private readonly string _morphName = FormattableString.Invariant($"dryl-canvas-{Interlocked.Increment(ref _morphSeq)}"); /// The shared per-canvas context — form state, pulse tracker and data binder. /// Exposed so a wrapper (e.g. DrylAiCanvas) can read the live field values. @@ -489,7 +489,7 @@ // Unconditional, cheap no-op when no transition is in flight — this is what tells the // browser the "new" state has reached the DOM and it may take the second snapshot. // It also covers a transition a wrapper started around a spec swap. - ViewTransition.SignalRendered(); + Morph.SignalRendered(); if (!firstRender) return; try @@ -549,7 +549,7 @@ // The morph between inline and fullscreen is a view transition, so the CSS only has to // describe the destination, never the movement. private Task ToggleExpandAsync() => - ViewTransition.RunAsync(() => + Morph.RunAsync(() => { _expanded = !_expanded; StateHasChanged(); @@ -569,11 +569,8 @@ // the attribute entirely and the CSS fallback takes over. private string? PopoverMode => _expanded ? "manual" : null; - // Rendered on the root so an expand — and any morph a wrapper runs around a spec swap — - // moves the whole surface. dryl-depth adds the mercury merge; data-vt-depth is the marker - // the JS bridge keys on to lazily inject the #dryl-merge filter. - private string VtStyle => FormattableString.Invariant( - $"view-transition-name: {_vtName}; view-transition-class: dryl-depth"); + // The name sits on the root so an expand — and any morph a wrapper runs around a spec + // swap — moves the whole surface; the depth marker gives it the DepthGlass pass. private string RootCssClass { diff --git a/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor b/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor index 967a6b3..245f041 100644 --- a/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor +++ b/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor @@ -2,7 +2,7 @@ @using DRYL.Components.Canvas @using DRYL.Components.Motion @inject IJSRuntime JS -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @inject IServiceProvider Services @implements IAsyncDisposable @@ -14,7 +14,7 @@ back to what they had. The workspace keeps those views side by side as chips and shows exactly one of them large. - Switching runs through IDrylViewTransition, so the surface morphs into the + Switching runs through IDrylMorph, so the surface morphs into the other view instead of snapping (one state change, one movement), while the shared [data-dryl-ink] indicator glides between the chips — the same primitive DrylTabs uses. @@ -259,7 +259,7 @@ // The morph belongs to the workspace, not to whatever sits in the body: nesting two view // transitions loses one of the mutations (DrylAiCanvas skips its own swap morph for a // switch, see DrylCanvasRun.ConsumeSwapMorphSuppression). - return ViewTransition.RunAsync(() => + return Morph.RunAsync(() => { Workspace.Activate(id); StateHasChanged(); @@ -287,14 +287,14 @@ () => FormattableString.Invariant($"Restored version {index + 1} of {Versions.Count}")); } - // A history step is a state change, so it is a movement: the same view-transition layer the + // A history step is a state change, so it is a movement: the same mow-transition layer the // view switch uses morphs the artifact instead of blinking it away. private async Task HistoryStep(Func step, Func announce) { if (Workspace is null) return; var moved = false; - await ViewTransition.RunAsync(() => + await Morph.RunAsync(() => { moved = step(); if (moved) StateHasChanged(); @@ -371,7 +371,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Tells the browser the switched-to view has reached the DOM (a cheap no-op otherwise). - ViewTransition.SignalRendered(); + Morph.SignalRendered(); if (!ShowBar) { diff --git a/code/DRYL.Components/Components/Data/DrylTable.razor b/code/DRYL.Components/Components/Data/DrylTable.razor index c625635..229ed9b 100644 --- a/code/DRYL.Components/Components/Data/DrylTable.razor +++ b/code/DRYL.Components/Components/Data/DrylTable.razor @@ -418,7 +418,7 @@ @ @{ var rowIndex = ReorderColumnVisible ? _view.IndexOf(item) : -1; } - /// Stable per-row id used to build each row's view-transition-name when + /// Stable per-row id used to build each row's morph name when /// is on. Falls back to GetHashCode() — stable /// for records and immutable rows, but pass an explicit selector (e.g. a database /// key) when row instances mutate in place. Ids must be unique per row; the value @@ -858,8 +858,8 @@ private int? _dragOverIndex; private int? _pendingFocusGrip; private ElementReference _rootEl; - private DrylViewTransition? _rowTransition; - private bool _rowTransitionBusy; + private DrylMorphEngine? _rowMorph; + private bool _rowMorphBusy; // Inline editing — one row at a time. _editBuffer is the item handed to the column // EditTemplates: a clone when CloneRow is set (so cancel reverts), otherwise the live item. @@ -940,18 +940,20 @@ (Ai == AiState.Streaming || Ai == AiState.Generated) && !Virtualize && GroupBy is null && DataProvider is null; - // Rows carry a view-transition-name whenever either morph mode could run, so the - // ::view-transition-group morph has stable per-row targets to glide between. + // Rows carry a morph name whenever either morph mode could run, so the engine has + // stable per-row targets to glide between. FLIP is the classic technique for a list + // reorder: each row is measured, the list re-renders, and every row that moved + // travels from its old position to its new one. private bool VtMorphActive => AnimateReorderActive || StreamGlideActive; - // Per-instance scope so view-transition-names stay document-globally unique when + // Per-instance scope so morph names stay document-globally unique when // several morph-enabled tables share a page — RowVtId only guarantees uniqueness // within one table, but the name must be unique across the whole document or the - // browser aborts the transition ("duplicate view-transition-name"). + // engine would move the wrong row (a duplicate name). private readonly string _vtScope = "t" + Guid.NewGuid().ToString("N")[..8]; - private string? RowVtStyle(TItem item) => - VtMorphActive ? $"view-transition-name: {_vtScope}-row-{RowVtId(item)}" : null; + private string? RowMorphName(TItem item) => + VtMorphActive ? $"{_vtScope}-row-{RowVtId(item)}" : null; private string RowVtId(TItem item) { @@ -961,7 +963,7 @@ return SanitizeVtName(raw); } - // view-transition-name must be a CSS custom-ident: keep [A-Za-z0-9_-], map the + // A morph name is an attribute value, but it is kept ident-safe: [A-Za-z0-9_-], map the // rest to '_'. The "tbl-row-" prefix guarantees a valid ident start. private static string SanitizeVtName(string raw) { @@ -982,16 +984,16 @@ // transitions serialise, so overlapping starts would only skip anyway. private async Task RunRowTransitionAsync(Func mutate) { - if (!VtMorphActive || _rowTransitionBusy) + if (!VtMorphActive || _rowMorphBusy) { await mutate(); return; } - _rowTransition ??= new DrylViewTransition(JS); - _rowTransitionBusy = true; + _rowMorph ??= new DrylMorphEngine(JS); + _rowMorphBusy = true; try { - await _rowTransition.RunAsync(async () => + await _rowMorph.RunAsync(async () => { await mutate(); StateHasChanged(); @@ -999,7 +1001,7 @@ } finally { - _rowTransitionBusy = false; + _rowMorphBusy = false; } } @@ -1143,7 +1145,7 @@ { // Tell an in-flight view transition that the mutated state reached the DOM // (no-op when none is running). - _rowTransition?.SignalRendered(); + _rowMorph?.SignalRendered(); if (firstRender && !string.IsNullOrEmpty(PersistStateKey)) { @@ -2109,7 +2111,7 @@ catch (Microsoft.JSInterop.JSException) { /* circuit gone */ } catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ } } - _rowTransition?.Dispose(); + _rowMorph?.Dispose(); _dotNetRef?.Dispose(); } } diff --git a/code/DRYL.Components/Components/Layout/DrylMorph.razor b/code/DRYL.Components/Components/Layout/DrylMorph.razor new file mode 100644 index 0000000..a550103 --- /dev/null +++ b/code/DRYL.Components/Components/Layout/DrylMorph.razor @@ -0,0 +1,111 @@ +@namespace DRYL.Components +@using DRYL.Components.Motion +@inject IDrylMorph Morph + +@* ───────────────────────────────────────────────────────── + DrylMorph — a transition ID for any content. + + Marks its content as a shared element: content that exists in two + views and should travel between them instead of disappearing and + reappearing. Wrap the same Name around the card in an + overview and around the heading of the detail view, and it glides + from one to the other while the page around it stays untouched. + + The consumer starts the morph through IDrylMorph; the hull reports + its own render back, so SignalRendered() never has to be written by + hand. + + Usage: + -- the overview -- + @foreach (var p in products) + { + + @p.Title + + } + + -- the detail: same Name, so the two are one object -- + +

@current.Title

+
+ + -- and the switch itself -- + await Morph.RunAsync(() => { openId = p.Id; StateHasChanged(); }); + + Renders one element and nothing else — no styling, no colour, no + frost. The movement's duration and easing are the shared motion + tokens; the hull adds no visual of its own. + + The element is real and takes part in its parent's layout, because + it is the thing being measured and moved. That is what As is + for: be an li in a list, a tr in a table. + ───────────────────────────────────────────────────────── *@ + +@Render + +@code { + /// The morph ID. Two elements carrying the same name before and after a + /// state change are treated as the same object, and the second is animated from + /// where the first was. A name must be unique among the elements live at the moment + /// the morph starts; see . + [Parameter] public string? Name { get; set; } + + /// How much of the "Depth Glass" vocabulary this element gets while it + /// moves — (default) or + /// . Ignored while the element claims no + /// name. + [Parameter] public DrylMorphStyle Style { get; set; } = DrylMorphStyle.Glide; + + /// The HTML tag rendered as this component's root. Defaults to "div"; + /// set it so the hull is valid where it sits ("li", "tr", + /// "article", …). + [Parameter] public string As { get; set; } = "div"; + + /// Whether this instance currently claims . Set false on + /// the entries of an overview that are not the morph target, so a long list neither + /// duplicates a name nor pays to be measured. + [Parameter] public bool Active { get; set; } = true; + + /// The content that morphs. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Extra CSS class(es) on the rendered element. The hull renders no class of + /// its own, so this is the only class it carries. + [Parameter] public string? Class { get; set; } + + /// Pass-through HTML attributes on the rendered element. + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } + + // Null unless this instance is actively claiming a name — an inactive or unnamed + // hull is not a morph target and is never measured. + private string? EffectiveName => + Active && !string.IsNullOrWhiteSpace(Name) ? Name : null; + + // Marker the JS engine keys on to give this target the DepthGlass treatment. + private string? DepthMarker => + EffectiveName is not null && Style == DrylMorphStyle.DepthGlass ? "" : null; + + // A .razor cannot both carry markup and choose its tag at runtime, so the element is + // built here — the same shape DrylTypo uses for its As parameter. + private RenderFragment Render => builder => + { + builder.OpenElement(0, As); + if (!string.IsNullOrWhiteSpace(Class)) builder.AddAttribute(1, "class", Class); + var name = EffectiveName; + if (name is not null) builder.AddAttribute(2, "data-dryl-morph", name); + var depth = DepthMarker; + if (depth is not null) builder.AddAttribute(3, "data-dryl-morph-depth", depth); + builder.AddMultipleAttributes(4, AdditionalAttributes); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + }; + + // The half of the contract that is easy to forget: the engine cannot measure the new + // geometry until the mutated state has reached the DOM, and this is what tells it + // that it has. Unconditional by contract — a cheap no-op when no morph is in flight, + // and reported even while unnamed, so an instance that is only the *destination* of a + // morph still closes the loop. + protected override void OnAfterRender(bool firstRender) => Morph.SignalRendered(); +} diff --git a/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor b/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor new file mode 100644 index 0000000..35632dc --- /dev/null +++ b/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor @@ -0,0 +1,102 @@ +@namespace DRYL.Components +@using DRYL.Components.Motion +@using Microsoft.AspNetCore.Components.Routing +@inject NavigationManager Nav +@inject IDrylMorph Morph +@implements IDisposable + +@* ───────────────────────────────────────────────────────── + DrylRouteTransition — morph across a real route change. + + DrylMorph covers a switch that happens on one route. This covers + /planets to /planets/42, a NavLink, and the Back button: mount it + once next to DrylDialogProvider, put a DrylMorph with the same + Name on both pages, and the object the user pressed travels with + them. + + Usage (in MainLayout.razor, beside the other providers): + + + -- exclude a navigation from morphing -- + + + It never prevents, cancels or restarts a navigation — it starts the + transition and gets out of the way — so history and the browser's + Back and Forward buttons behave exactly as they would without it. + + Renders no markup. + ───────────────────────────────────────────────────────── *@ + +@code { + /// Decides whether a navigation morphs, given the URI it is heading to. + /// Null (the default) morphs every internal navigation. Return false to leave a + /// navigation alone — a sign-out, or a jump to a part of the app that shares + /// nothing with the current page. + [Parameter] public Func? ShouldMorph { get; set; } + + /// How long the previous frame may be held while waiting for the + /// destination page to report a render, before the navigation completes without a + /// morph. A destination that carries no , or that never + /// finishes rendering, must not leave the user looking at a frozen frame. + [Parameter] public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); + + private IDisposable? _registration; + private bool _awaitingRouteRender; + + // Registered from OnAfterRender, never OnInitialized: during prerender there is no + // JS to start a transition with, and a handler registered there would outlive + // nothing useful. + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _registration ??= Nav.RegisterLocationChangingHandler(OnLocationChanging); + Nav.LocationChanged += OnLocationChanged; + return; + } + + if (_awaitingRouteRender) + { + _awaitingRouteRender = false; + // The floor under the timeout. A destination carrying a DrylMorph reports in + // the same batch and this changes nothing; a destination carrying none would + // otherwise hold the old frame for the whole of Timeout, which would make + // every ordinary navigation in an app feel a second slow. Reporting on the + // route's first render is also exactly the "morph onto the skeleton" policy: + // a page still loading its data morphs into its placeholder rather than + // freezing the page it came from. + Morph.SignalRendered(); + } + } + + // Queues one render of this component behind the router's own, so the report above + // lands after the new route reached the DOM. + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + _awaitingRouteRender = true; + InvokeAsync(StateHasChanged); + } + + private ValueTask OnLocationChanging(LocationChangingContext context) + { + // Note what this does NOT do: no PreventNavigation, no await. The old snapshot + // is taken inside BeginNavigation; holding the handler open would deadlock, + // because the transition is waiting for a render that cannot happen until this + // returns. + if (ShouldMorph?.Invoke(context.TargetLocation) is not false) + { + // Deliberately not awaited: the navigation must not wait for the morph, and + // awaiting here would deadlock — the morph waits for the new page's render, + // which cannot happen until this handler returns. + _ = Morph.BeginNavigationAsync(Timeout); + } + return ValueTask.CompletedTask; + } + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _registration?.Dispose(); + _registration = null; + } +} diff --git a/code/DRYL.Components/Components/Surfaces/DrylCard.razor b/code/DRYL.Components/Components/Surfaces/DrylCard.razor index b0358b4..70bbe18 100644 --- a/code/DRYL.Components/Components/Surfaces/DrylCard.razor +++ b/code/DRYL.Components/Components/Surfaces/DrylCard.razor @@ -27,8 +27,6 @@
@if (_aura.Present) { @@ -88,23 +86,7 @@ /// Extra CSS class(es) merged onto the card's own classes. [Parameter] public string? Class { get; set; } - /// - /// Marks this card as a shared-element morph endpoint for view transitions - /// (): renders view-transition-name - /// with this value. Must be a valid CSS identifier (letters, digits, -, - /// _) and unique among elements simultaneously in the DOM — a duplicate - /// name voids the entire transition. Supply a stable per-instance id, same - /// discipline as @key. Null (default) opts out. - /// - [Parameter] public string? ViewTransitionName { get; set; } - /// - /// How much "Depth Glass" a morph of this card gets — - /// (default, viscous shape settle only) or - /// (adds the translucency pulse + mercury merge; reserve for rare, high-meaning merges). - /// Ignored unless is set. - /// - [Parameter] public DrylViewTransitionStyle ViewTransitionStyle { get; set; } = DrylViewTransitionStyle.Glide; private ElementReference _el; private bool _tracking; @@ -128,19 +110,6 @@ _aura.Sync(EffectiveAi, () => InvokeAsync(StateHasChanged)); } - private bool VtDepth => - !string.IsNullOrWhiteSpace(ViewTransitionName) - && ViewTransitionStyle == DrylViewTransitionStyle.DepthGlass; - - // Marker attribute the JS bridge keys on to lazily inject the #dryl-merge filter. - private string? VtDepthMarker => VtDepth ? "" : null; - - private string? VtStyle => - string.IsNullOrWhiteSpace(ViewTransitionName) - ? null - : VtDepth - ? $"view-transition-name: {ViewTransitionName}; view-transition-class: dryl-depth" - : $"view-transition-name: {ViewTransitionName}"; private string CssClass { diff --git a/code/DRYL.Components/DRYL.Components.csproj b/code/DRYL.Components/DRYL.Components.csproj index 007c6ef..02cd703 100644 --- a/code/DRYL.Components/DRYL.Components.csproj +++ b/code/DRYL.Components/DRYL.Components.csproj @@ -5,7 +5,7 @@ DRYL.Components - 2.24.3 + 3.0.0 DRYL — Blazor Component Library DRYL is a dark, glassy, AI-native UI component library for Blazor Server and Blazor WebAssembly. Token-driven, accessible by default, with a shared AI-state visual vocabulary (Active / Thinking / Streaming / Generated) across every surface — and zero JavaScript framework dependencies. blazor;blazor-components;ui;components;razor;dark;glassmorphism;ai;design-system;blazor-server;blazor-webassembly diff --git a/code/DRYL.Components/Dialogs/DialogOptions.cs b/code/DRYL.Components/Dialogs/DialogOptions.cs index 76eb5c3..4ef02af 100644 --- a/code/DRYL.Components/Dialogs/DialogOptions.cs +++ b/code/DRYL.Components/Dialogs/DialogOptions.cs @@ -31,8 +31,7 @@ public sealed class DialogOptions /// /// When true, a dialog opened while a sibling is still closing (the sequential /// "agent handoff" pattern — see the Sequential demo) morphs into the new one via - /// the browser's - /// View Transition API (): + /// the browser's morph engine (): /// the dialog shell glides to its new size/position while its title, body and /// footer cross-fade independently, instead of the default CSS cross-fade /// (predecessor plays its exit while the successor enters). Off by default — @@ -45,12 +44,12 @@ public sealed class DialogOptions /// /// Morph tier for the transition. Defaults to - /// — a dialog handoff is exactly + /// — a dialog handoff is exactly /// the rare, high-meaning merge that tier is for: the mercury-merge + translucency /// pulse makes the content swap read as a deliberate change even when the dialog's /// size barely moves (e.g. two confirm dialogs of similar length), instead of - /// looking like a plain text cross-fade. Set to + /// looking like a plain text cross-fade. Set to /// for the cheaper shape-only morph. Ignored unless is true. /// - public DrylViewTransitionStyle HandoffStyle { get; set; } = DrylViewTransitionStyle.DepthGlass; + public DrylMorphStyle HandoffStyle { get; set; } = DrylMorphStyle.DepthGlass; } diff --git a/code/DRYL.Components/Dialogs/DrylDialog.razor b/code/DRYL.Components/Dialogs/DrylDialog.razor index cd16b7a..b019034 100644 --- a/code/DRYL.Components/Dialogs/DrylDialog.razor +++ b/code/DRYL.Components/Dialogs/DrylDialog.razor @@ -19,15 +19,15 @@ role="dialog" aria-modal="true" aria-labelledby="@_titleId" - style="@RootVtStyle" - data-vt-depth="@VtDepthMarker" + data-dryl-morph="@MorphName("")" + data-dryl-morph-depth="@MorphDepthMarker" @attributes="AdditionalAttributes"> @if (ShowHeader) { -
+
@if (!string.IsNullOrEmpty(Icon)) { @@ -50,13 +50,13 @@
} -
+
@ChildContent
@if (ActionContent is not null) { -