Skip to content

Continuous motion for operator-mode navigation - #6325

Draft
christse wants to merge 30 commits into
mainfrom
choreo/return-latency
Draft

christse wants to merge 30 commits into
mainfrom
choreo/return-latency

Conversation

@christse

@christse christse commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Background and Goal

Operator mode changed scenes by swapping DOM: a card opened by appearing, a stack closed by vanishing, and the dashboard jumped to a workspace. This PR gives every one of those navigations continuous motion without scaling live text or making layout part of playback.

Two mechanisms split the work:

  • Choreo (glimmer-motion, vendored) tweens live geometry within a scene: stack reflow, the header handoff to the top bar, and the search sheet.
  • Bitmap crossings (view transitions) handle navigation between scenes:
    • a preview becomes a stack card and returns to its tile;
    • a card expands and restores;
    • a search result opens;
    • a dashboard tile becomes the realm background and back.

A single service, HostMotionService, decides what may move at any moment. Everything moves on one ease-out curve.

docs/host-motion.md is the architecture reference; this description summarizes it.

Where to start

  1. services/host-motion.ts: the policy.
    • begin(scene) arms one Choreo scene at a time.
    • cross() is the only entry for a bitmap crossing. It gates on tests, reduced motion and drag, owns the bitmap budget and the test waiter, and waits for the update to render before the new capture.
    • canCross() exposes the gate for callers that have their own fallback.
  2. lib/bitmap-crossing.ts: the mechanism, crossfadeCardBitmap(crossing). The fields of BitmapCrossing (from, to, update, handoff, parent, scenes, companions, chrome) describe every layer a crossing captures. Anything in the updated document is a lookup function, called after the update and given the landing face.
  3. components/operator-mode/interact-submode.gts (viewCard, close): the main callers. Read them for opening over a parent, returning into a tile, opening into a new stack with reflowStacks, and settling a right-stack card back into its tile while the other stacks morph.
  4. lib/card-open-origin.ts: which preview a crossing starts from and returns to, including a card shown twice and a click beside a tile.
  5. lib/workspace-open-origin.ts (workspaceEntry, workspaceExit): the dashboard ↔ workspace crossing, defined once for both directions.
  6. services/operator-mode-state-service.ts: where the Choreo stack scene is armed (open, close, stack removal), and crossToDashboard, the single gate for the dashboard button and for closing the last card.

The remaining files are supporting pieces:

  • stack-motion.gts, search-sheet/motion.gts and workspace-scene.gts: the Choreo regions.
  • bitmap-shadow.ts: shadow layers.
  • prerendered-placeholder.ts: the stand-in body while a card flies.
  • inspect-host-motion.ts: instrumentation.
  • The two dependency patches.

Key decisions and non-obvious mechanics

  • Bitmaps for boundaries, live geometry for reflow. Crossing a card between two layouts as live DOM would either stretch text or re-lay out the card on every frame. Instead the live DOM sits at its final layout while two cropped raster faces morph. Choreo never scales a card; the only surfaces it scales are empty ones (the search sheet's and a header's).
  • Navigation never depends on motion. A crossing's update always runs its navigation, even when a newer crossing has taken over, and a motion failure after it is logged rather than thrown. One canCross() decides everything a crossing implies, including whether to defer the card body and fetch its placeholder.
  • A crossing owns the scene. While a crossing plays, Choreo scenes are refused and the stack region is held instant. There are two exceptions:
    • A card flying into a new stack (reflowStacks) leaves the existing stacks live, and they reflow with Choreo beneath it.
    • When a right-stack card returns to its tile, the remaining stacks move as morph layers at their natural size, so a widening stack is revealed rather than scaled.
  • Origins are explicit, and so are return addresses.
    • A crossing starts from the preview that holds the click. A click beside a tile resolves to the nearest preview; otherwise a unique visible preview is used.
    • The chosen preview is remembered per parent. A buried parent hides every preview, and a card rendered twice would be ambiguous on the way back.
    • A card opened into its own stack keeps StackItem.returnTo, in memory only.
    • If a close can't resolve a tile, it doesn't guess: the card just fades.
  • Dashboard ↔ workspace.
    • The tile's realm icon flies on its own to the first card's header. Naming it keeps it out of the tile bitmap, which would otherwise blow it up across the background.
    • The platter of cards scales and fades up from about the icon's size, centred on the growing background.
    • The return needs only the realm whose background is showing, so it also works after a reload, from a pasted URL, or with several stacks open.
    • When the header has no icon yet (a cold realm) or at all (no icon URL), the icon lands on a stand-in.
    • The dashboard no longer scrolls on open, so it stays still under a landing crossing.
  • One curve. Every geometric motion uses motionEase (cubic-bezier 0.2, 0.8, 0.2, 1): it leaves at once, since the click has already waited for capture, and lands without overshoot. Motions that play together share a duration.
  • Performance rules the code follows (nothing checks them automatically):
    • View-transition CSS is installed once, because any stylesheet change during motion restyles the whole document.
    • Shadow layers live in one persistent host element.
    • Crossings read every participant's styles before writing any.
    • Idle Choreo regions skip measurement.
    • A leaving card releases focus before capture.
    • The deferred card body mounts one paint after landing, with the prerendered isolated HTML standing in (up to 100 KB).
  • Browser view-transition animations are replaced, not retimed. Chrome sampled a retimed CSSAnimation inconsistently, so layers lurched between two easing curves. The motion-dom patch copies their keyframes into WAAPI animations instead.
  • Dependencies. glimmer-motion has no npm release yet, so it is vendored as a tarball. It and motion-dom carry pnpm patches, listed in vendor/glimmer-motion.md; both should move upstream.
  • Tests and measurement.
    • All motion takes no time in tests, and crossings and deferred bodies hold waiters.
    • ?motionSpeed, ?motionTrace and ?motionInspect are opt-in review tools. motionInspect records every frame of every view-transition layer and flags reversals, spikes and long frames.
    • The remaining cost is structural: motion starts 50–80 ms after a click (old capture, update render, new capture).

🤖 Generated with Claude Code

christse and others added 23 commits September 19, 2026 12:47
…26-09-22)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- motion-dom patch: install the view-transition rules once as a static
  stylesheet keyed on view-transition-class tokens and a root class.
  Rewriting a stylesheet around each capture forced a full-document
  restyle before the old snapshot, after the update, and at cleanup.
- Keep bitmap shadow layers in a persistent display: contents host;
  removing direct <body> children restyles the whole document.
- Release focus from the closing card before the transition. Removing
  the focused Close button mid-update made focus fixup recalc style
  synchronously.
- Settle a crossing when the browser skips its view transition on
  viewport resize (or the clock has run out). motion's finished never
  settled, leaving the destination body unmounted and cleanup pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sings

- glimmer-motion patch: Choreo takes an @armed argument. When nothing is
  armed and no run, leaver or claim is in flight, a render skips both
  measurement passes instead of reading every participant's rect.
  Typing in a large card went from 608 layout reads (104 ms) to 5.
- The stack region counts a workspace portal or a non-bitmap dock
  opening as armed; both score their own steps, and skipping their
  passes left the index card invisible after opening a workspace.
- Opening a card through a bitmap crossing fetches the card's indexed
  isolated HTML and shows it, inert, in place of the deferred live body
  as soon as it arrives. The incoming view is live during the transition,
  so light cards show content from the first frame of the flight. Large
  renderings (over ~100 KB) are skipped: parsing and inserting them
  stuttered the flight.
- The crossing releases focus inside a leaving card before capture;
  removing the focused Close button mid-update forced a synchronous
  style recalculation.
- Finished view-transition animations are cancelled when a crossing
  ends; about 34 were retained per crossing for the rest of the session.
- The search input focuses with preventScroll, avoiding a forced layout
  of the half-mounted search panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	packages/host/app/services/loader-service.ts
#	packages/host/app/services/operator-mode-state-service.ts
#	packages/runtime-common/loader.ts
A parent that renders the same card more than once (a fitted tile and an
embedded row) made the origin lookup ambiguous, so the card opened without
its tile flight and closed without a return. The preview containing the
activating click is now the origin, and it is kept as the return address
for the buried parent.

Also:
- close() records why a return was skipped (motionTrace only)
- crossings take no time in tests and hold a test waiter until the
  crossing and any deferred body are done

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a card never armed the stack scene, a scene without a primary card
reflowed nothing, and the blur after the Close click re-closed the
already-closed search sheet, whose new scene finished the reflow at once.
Closing now arms the stack scene (with no primary when the whole stack goes,
so every kept card reflows), and closing a closed sheet starts no scene.
host-motion records begun and refused scenes under motionTrace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A card opened into a new right-hand stack crossed as a bitmap while the
stack region was held instant, so the existing stacks snapped to their new
widths under the flight. That crossing now leaves the stacks live: it passes
no underlay (its source card stays on top of its own stack), and the stack
scene may arm during it with no primary, so every existing stack reflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Expanding or restoring a card changed its frame in one frame: the reflow
only translates a card from its old centre, which does not move here. The
card now crosses its own two faces as a bitmap while its frame morphs.
Expanding keeps the narrow face for most of the move and hands over near
the landing ('late' handoff), so the wide layout is never shown squeezed.

The crossing also accepts whole-scene layers that fade out or in around
it, for the workspace tile crossing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a workspace from its dashboard tile now uses the same bitmap
crossing as a fitted card opening to isolated: the tile's wallpaper crosses
into the full realm background while the dashboard fades out beneath it and
the index card fades in over the landing. Opening the dashboard again crosses
the background back into its tile. The clip-path portal remains the path in
tests and wherever the crossing is unavailable. The tile element rides on the
open origin only for the crossing and is never kept on the stack item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- motion-dom: replace the browser-generated view-transition animations with
  WAAPI copies instead of retiming them. Chrome sampled a retimed
  CSSAnimation inconsistently (some frames with the effect easing, others
  with the CSS linear timing), so layers lurched forward and back.
- The playback deadline treated the crossing duration (seconds) as
  milliseconds, so any crossing longer than ~500 ms was jumped to its end.
- The bitmap shadow now hands back at its resting value: transitions stay
  off through the landing paint, so the card's shadow no longer fades in
  from none after the flight.
- ?motionInspect records every frame of every view-transition layer and
  summarizes reversals, spikes, face jumps and long frames per crossing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a card opens over its parent, the parent's header used to morph from
its old place up into the buried strip, so the new card's rising edge
uncovered it from below. It now enters the strip as its own layer, sliding
down one header-height from under the top bar, with the title and realm
icon inside it; its old place fades with the parent's face. Closing still
matches the header and scales its title back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A card opened into its own stack from another stack's card remembers that
card (in memory only). Closing it, when that card is still on top and the
tile is visible, crosses the card back into the tile while every remaining
stack morphs into the freed width. A click beside a tile (an open-in-new-
stack strip) now resolves to that tile rather than the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every crossing (open, return, expand, search pick, workspace tile) used to
repeat the same host policy by hand: gate on tests, reduced motion and drag,
take and release the bitmap budget, hold a test waiter, tag the landing
element with a one-off data attribute after the update, and wait for
afterRender before the new capture. That now lives in hostMotion.cross(),
whose `to` is a function returning the landing element once the update has
rendered. hostMotion.canCross() exposes the gate for callers with their own
fallback (a search pick docks with Choreo when no bitmap can play).

crossfadeCardBitmap() takes one options object instead of nine positional
arguments; its fields are documented on BitmapCrossing.

A dashboard tile's rounding belongs to its card container, so the tile
image adopts the container's corners during a workspace crossing and they
tween to and from the square realm background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/host-motion.md describes the current system in one place: live Choreo
geometry versus bitmap crossings, HostMotionService's policy and its cross()
entry, the anatomy of a crossing, origins and return addresses, timing, the
measured performance rules, and the trace and inspection tools. The vendor
notes now describe both dependency patches as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the dashboard only crossed back into a tile when the workspace had
exactly one stack whose index still carried the tile origin it was opened
with, so it silently didn't animate after a reload, a pasted URL, or with a
right-hand stack open. The return needs only the realm whose background is
showing: it now finds that realm's tile on screen after the update,
preferring the favourite or catalogue copy it was opened from, and fades the
background when no tile is visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shboard tile

The stacks now follow the realm background out of the dashboard tile:
they start at 30% of the crossing, are opaque by 70% and settle from
0.94 on the boundary spring, zooming about the tile. Returning, they
shrink toward the tile and fade by 35%, ahead of the background.

Closing the realm's index card, the last card on screen, now takes the
same crossing back to the dashboard tile instead of cutting to it.

A dismissed right stack moves each remaining stack as one layer, so a
top card and the parents buried under it move together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dashboard to workspace, the realm icon leaves the tile bitmap as a
companion layer and flies to the first card's header, while the platter
of cards grows out of it on the boundary spring (a 'rise' scene seeded
by the icon). Scaling about the icon keeps the header's icon slot close
to the flying icon throughout. The return mirrors it with a 'fall'
scene, and the dashboard fades in from a quarter of the way so the
middle of the crossing is never empty.

Crossings between the dashboard and a workspace crossfade the top bar
('chrome: crossfade') rather than cutting to the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every lookup in the updated document is now a function called after the
update, given the landing face: to(), a companion's to(landing), a fall
scene's seed(landing). The mechanism tags the landing itself, so cross()
no longer keeps its own landing bookkeeping. The dashboard <-> workspace
crossing is defined once (workspaceEntry / workspaceExit) and the
dashboard return has one gate (crossToDashboard) for the dashboard button
and for closing the last card.

The platter now scales and fades up centred on the crossing card's frame,
from the middle of the tile at about the realm icon's size. A cold realm's
entry waits up to 300 ms for the header's realm icon so the flying icon
has somewhere to land; an unlanded companion is traced.

A reflowing neighbour stack keeps its faces at natural size while its
frame widens, instead of scaling up under a covering crop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cold realm's index card is still loading when the workspace crossing
captures, and a realm without an icon URL renders an empty header slot,
so the flying realm icon had nowhere to land and stayed on the tile. It
now lands on a stand-in: a copy of the tile's icon where the header's
will sit while the card loads, or an empty stand-in in the empty slot.
The return starts from the same empty stand-in, which also keeps the
icon out of the landing tile's bitmap. The header wait drops to 150 ms.

The workspace chooser focused its default tile with scrollIntoView each
time a tile was re-created by arriving realm info, scrolling the
dashboard under a landing crossing so the return ended a row off. It now
focuses without scrolling unless arrow keys moved the selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choreo's live geometry used an ease-out bezier while bitmap crossings
used a critically damped spring, so a card opening into a new stack and
the stacks reflowing beside it arrived on different curves and clocks.
The spring also started from rest, adding slow frames after a click that
had already waited for capture.

Every geometric motion now uses motionEase (cubic-bezier 0.2, 0.8, 0.2,
1): it leaves at once and lands without overshoot. Sampled keyframes
use motionEaseAt. Card reflow and card open share 0.32 s. The spring
and boundaryEase/boundaryReturnEase are gone; crossings take the
shared curve by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/host-motion.md is the reference for host motion; these planning
notes and the sample recording describe designs since replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   1h 27m 39s ⏱️ - 4m 3s
5 153 tests +7  5 139 ✅ +7  14 💤 ±0  0 ❌ ±0 
5 168 runs  +7  5 154 ✅ +7  14 💤 ±0  0 ❌ ±0 

Results for commit bab0af9. ± Comparison against earlier commit 21cae06.

Realm Server Test Results

    1 files  ±  0    281 suites  +8   2h 9m 1s ⏱️ + 12m 39s
4 458 tests +169  4 458 ✅ +169  0 💤 ±0  0 ❌ ±0 
4 510 runs  +169  4 510 ✅ +169  0 💤 ±0  0 ❌ ±0 

Results for commit bab0af9. ± Comparison against earlier commit 21cae06.

christse and others added 5 commits September 25, 2026 22:51
Opening a card over its parent slid the parent's header into the buried
strip from under the top bar as a new layer, so for a moment the strip
and the card's own header both showed and nothing travelled between
them. The header is now one matched object in both directions, with its
title and realm icon matched separately: opening, it rises from its
place on the card into the strip; returning, it comes back down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing the only card on screen, when it is not the realm's index card,
put the index card in its place without motion. A new crossing handoff,
'replace', covers a different card taking the departing one's place: the
faces never share a frame; the closing card recedes toward the top of
its slot and is gone by 40%, and the index card surfaces there from 94%,
opaque between 20% and 60%. Platter and replacement keyframes share one
sampler.

The Boxel button, account, search and AI now sit on their own topmost
plane, each its own natural-size layer whose faces cross additively, so
they never fade with the top bar between the dashboard and a workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Between the dashboard and a workspace the top bar crossfaded in place.
With chrome: 'summon' its controls travel instead: the leaving set
(View All, or Interact and New) rises out of the top edge and fades by
40%, and the arriving set drops in from above from 45% on the motion
curve. The AI panel is captured separately and stays still; the app's
own controls stay pinned on the persistent plane.

Scenes and reflowing neighbour stacks had no z-index and depended on
document order; they now sit on plane -1, completing an explicit ladder
documented in app.css and host-motion.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
header-motion-parts marked the header with classList, but the header's
class attribute is bound in its template, so any re-render dropped the
class. Whether a header was transparent over its motion surface, or
painted itself as well, depended on render timing, which made 'stack
item with custom header color does not lose the color' fail in CI when
it read the header's own background. The modifier now marks the header
with a data attribute the template never rewrites.

The test checks the header's text colour on the header and its
background on the surface layer that paints it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stack header's colours and icon/action widths moved from a bound
style attribute to Choreo's motion modifier style argument, which is
applied only when the modifier installs. A header colour that arrived
with its card definition, or a header becoming buried (95px -> 50px
slots), never reached the header, which failed 'stack item with custom
header color does not lose the color'. A cssVariables modifier now sets
each variable with setProperty and re-applies on change; it never
rewrites the inline style, so transforms Choreo writes during motion
survive re-renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@burieberry

burieberry commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Bugs

  1. A close or open can be lost. The stack change runs inside the crossing's update, and update exits early when another open or close has started since (interact-submode.gts:464, 661). If you close a card and quickly open another, the closed card stays on the stack, and a card in edit mode never finishes. Two quick opens drop the first card the same way.
  2. Motion can stay off after a workspace portal. Some paths cancel the portal without clearing workspaceActive: enterWorkspace(..., portal=false), for example the AI open-workspace tool mid-portal, and WorkspaceScene torn down when switching to code mode. Stack, header and sheet motion then stay off until a later crossing, drag or portal calls finish() (operator-mode-state-service.ts:1598).
  3. Reopening the chooser leaves no tile focused. Now that the chooser stays mounted, focusWhenSelected doesn't run again on reopen. Arrow keys and Enter do nothing until the user tabs in, and the old selection ring comes back (workspace-chooser/focus-when-selected.ts:38).
  4. A search pick with no crossing keeps its tile alive. In the dock fallback (reduced motion, unsupported browser, a drag, and every test run), the whole origin, including the search-result element, goes onto StackItem.openingOrigin and is never cleared (submode-layout.gts:409-413). The detached result stays in memory for the card's lifetime, and StackMotion.measuring stays true, so the stack region measures on every render (stack-motion.gts:63). The other paths strip source; this one should too, and clear the origin once the dock motion is done.

Architecture

  • Navigation depends on the animation. The check that cancels a stale animation also cancels the navigation, which is bug 1. The stack change should always run, and only the visuals should be skipped.
  • Motion state lives in scattered flags. HostMotionService has seven flags set and cleared in different places, with no clear owner for resetting each one. Bug 2 comes from this, and an explicit state (idle / scene / crossing / portal / dragging) would prevent the whole class.
  • Two gates disagree. viewCard decides to defer the body and fetch the placeholder before cross() decides whether a crossing can play. crossfadeCardBitmap also refuses when a modal is open, and canCross() doesn't know that. When they disagree, the user waits for a crossing that never plays. deferContent should come from the same canCross() call, and canCross() should include the modal check.
  • Motion logic in the state service. Screen changes such as openWorkspace now go through motion-aware code and global DOM queries.
  • Class-name lookups. Crossings find their layers through other components' classes (.chat-btn, .submode-layout-top-bar, …), so a rename silently drops a layer and no test would notice. workspaceHeaderIcon, headerIconStandIn and interact-submode.gts:574 take the first .stacks .operator-mode-stack … in the document, which is stack 0 when two are open. Keyed data-* hooks like [data-bitmap-entry] or component refs would fix both.
  • CardHeader overrides. app.css:216-243 forces CardHeader's background, shadow and outline off with !important and repaints editing via its internal .is-editing class. That belongs in CardHeader's CSS variables.
  • Durations are handled inconsistently. Crossings use their own isTesting() ? 0 : x instead of MotionTiming.duration().
  • Errors after navigation reject the caller. Once update has run, a later motion error is rethrown, so viewCard, close and openWorkspace reject even though the navigation succeeded. Log it and swallow it instead.

Dependencies

  • The vendored tarball can't be rebuilt. vendor/glimmer-motion.md says it was packed with uncommitted working changes, so it doesn't match any commit.
  • Motion's copyright notice is missing. The tarball's VENDORED.md says gestures, reorder and features were copied word for word from Motion (MIT), but its LICENSE has only the Cardstack notice. MIT requires Motion's notice in the copy.
  • Unused code in the host bundle. glimmer-motion has no sideEffects field, and its index imports film/* purely for side effects. The host likely ships film plus the gesture and reorder code it doesn't use: about 110 KB+ gzip before minification, not measured in a real build. Adding "sideEffects": false and giving film its own entry point would fix it.
  • motion-dom patch:
    • It copies each view-transition animation's keyframes once. If Chrome returns the group's end state as a fixed value, the copy stops following the landing element, and the card jumps at the end whenever the landing moves mid-flight (placeholder fill-in, reflowStacks). I haven't confirmed this in a browser, but it matches a jump I saw near the end at slow speed. Dropping the offset-1 keyframe would let the end come from the live style.
    • The replacement animations use fill: "both", and the patch never cancels them. Only one host finally does (releaseFinishedViewAnimations).
    • It's declared as ^13.3.0 while the patch targets exactly 13.3.0. Pin it, via catalog: like the host's other dependencies.
    • supportsBitmapCrossing() should also check CSS.supports('view-transition-class', 'a'). Without it, Chrome 111–124 and Safari 18.0–18.1 crop incorrectly.

Tests

  • Crossings are only tested in isolation. Fixtures call crossfadeCardBitmap directly, and canCross() is false under isTesting(), so viewCard/close, the placeholder fetch and workspaceActive never run with a crossing playing. None of bugs 1–3 can show up in CI. Routing crossing durations through MotionTiming would let a test set a non-zero duration and cover them.
  • Hang risks. The bitmap tests await bitmapReady with no supportsBitmapCrossing() guard or deadline, and five while (!isMotionIdle()) loops have no deadline. A skipped transition or a regression hangs the shard instead of failing.
  • Selectors and mocks. 39 test queries use classes or non-test data-* attributes, and four browser globals are monkey-patched.

Performance (questions)

  • The deferred body. A heavy card is usable about 0.35s later than on main, because its body waits for the crossing to land. I understand why, but it's paid on the most frequent action. Is that settled, or worth weighing against a shorter open duration?
  • The placeholder request. There's an extra _federated-search on every animated open from a preview (interact-submode.gts:437). It isn't aborted on landing, it's still sent when the crossing is skipped after the fact (see "Two gates disagree"), and it downloads the full response before checking the 100 KB cap. It also skips assertOwnRealmServer, which the other federated calls use.
  • The hidden chooser. It stays mounted with only visibility: hidden, so every full-page style recalc includes the dashboard. content-visibility: hidden would keep its state and skip that work.

Accessibility

  • Closing a card sends focus to <body>. The PR already records the element that opened the card, so focus can go back there.
  • Tiles outside the render window can't be reached by screen readers or find-in-page (tile-window.gts:44).
  • Minor: leaving cards can be reached with Tab while they animate out, and the buried card header only works with a mouse.

UI

  • The search sheet's corners and shadow stretch as it opens, because width and height scale separately. A clip-path tween, like workspace-scene.gts uses, would avoid it.
  • Opening the chooser no longer scrolls the selected tile into view, even with reduced motion or no view transitions, where nothing lands on it.
  • Minor: there are two shadow systems on one screen, some baked shadow layers are too faint to see, and there's a deprecated -xxl radius token.
  • For slow-motion review, use ?motionSpeed. The DevTools playback rate doesn't slow playbackSettled's wall-clock deadline, so a crossing snaps to its end at about 75–85% of the way through.

Description and doc

  • "Choreo animates only positions, never scale" isn't the case: the sheet and header surfaces use scaleX/scaleY (lib/motion-transform.ts:40-57).
  • "Performance rules are enforced in code": the code follows them, but nothing checks them.
  • The rule that blocks scenes during a workspace portal isn't in the doc's policy list.
  • The cross() example leaves out 'replace', and one close duration is wrong.

Reduced-motion support, the opt-in diagnostics and the single cross() entry point all look solid. The vendored tarball is otherwise clean: no install scripts, no network or eval calls, and its integrity matches the lockfile.

🤖 Drafted by Claude

christse and others added 2 commits September 28, 2026 15:47
…ooks

Bugs:
- A crossing's update always runs its navigation. It used to return early
  when a newer open or close had started, so a quick close-then-open left
  the closed card on the stack (and an edit never finished), and two quick
  opens dropped the first card.
- The workspace portal is set and cleared only through one setter, which
  ends workspace motion on every path, and WorkspaceScene ends its portal
  when torn down mid-flight (switching to code mode). Stack, header and
  sheet motion no longer stay off after a dropped portal.
- Reopening the mounted chooser clears the stale selection ring, focuses
  the default tile and scrolls it into view unless a crossing is landing.
- A search pick without a crossing passes only the tile's geometry (never
  the result element) and releases the origin once the dock plays, so the
  stack region stops measuring every render.

Gates and errors:
- canCross() is the one gate: it includes the modal check and whether
  view-transition classes are supported, and viewCard defers the body and
  fetches the placeholder only when it passes.
- Crossing durations resolve in HostMotionService.duration(); tests can set
  crossingsInTests to play them.
- A motion failure after the navigation ran is logged, not rethrown.

Lookups and cost:
- Chrome layers are found by data-motion-chrome hooks, and the workspace
  card by its stack item's instance (data-stack-item), never the first
  stack in the document.
- The hidden chooser uses content-visibility: hidden.
- Closing a card returns focus to the preview it lands on.
- motion-dom and motion-utils are pinned to 13.3.0 through the catalog.
- Motion tests wait with named deadlines instead of unbounded loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/return-latency

# Conflicts:
#	packages/host/app/components/operator-mode/stack-item.gts
#	packages/host/app/components/operator-mode/stack.gts
@ef4

ef4 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor
  1. We should use this branch as a high-fidelity spec for how the UX should look and feel.

  2. We should not use this branch for illustrating how animation composition is defined and how the state is managed. Animation concerns are leaking all over the place, in a way that's not tenable for a system with end-user authored components.

    An example of this is all the places where existing components had to adapt their behavior by becoming aware of the motion service or (even worse) directly inspecting the DOM to see if animations are happening.

  3. The bugs mentioned in @burieberry's review are real and significant. I think a major reason agents are struggling to write correct code in this area is that our stack state management has grown extremely messy. A starting point for "what to refactor" is "everything that touches a StackItem". This will pay dividends by making it much easier to land this kind of feature going forward.

  4. The integration here doesn't follow the architecture we've been discussing. By the game engine analogy, we want objects in the world to go through their own state changes without interleaving animation logic. The animation engine is supposed to be a completely separate layer that reacts to state changes, and in particular never blocks them or break them if something goes wrong with animation.

    A concrete example of what's wrong is having imperative methods like hostMotion.cross(). We want to build cards that feel like physical objects and that retain that feeling in new environments. They need to carry their physicality with them at a much lower level (down in the components that implement card-api-level boundaries and also probably in some semantically-relevant UI toolkit components).

    Another concrete example (and source of correctness bugs) is a field like newItem.deferContent. The application logic shouldn't be sitting around waiting for animations to do anything.

  5. There are some good ideas in the branch that could be extracted and landed on their own. For example, something like the TileWindow component (which provides lazy rendering based on an IntersectionObserver) would make sense as a primitive in the UI toolkit.

  6. The inspection system with its ability to slow down animations is a tell that we're not consistently using the browser animation APIs. If we were, we wouldn't need our own inspection system. Chrome devtools can already slow and inspect browser animation APIs.

My recommended next actions are:

  • have somebody work on cleaning up the stack state management
  • pick a particular existing animation and do a one-to-one replacement with choreo, just in that one spot. This will create real design pressure on choreo so that we can make it work well within host, while keeping the scope reasonable for a first integration.
  • the key piece of work that we've always wanted to do in boxel motion is simply not done yet and choreo doesn't move it forward: an animation engine that is purely reactive to UI changes, where low-level UI components have declarative semantics about how they could move, and screen-level UI components have declarative semantics for how possible changes should move, and behind it all the reactive animation engine manages all imperative control (deferring as much of that as possible to the browser's native animation loops).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants