Repository navigation
feat(lab): NavigationStack, push/pop navigation that moves like UINavigationController - #9697
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report🔎 Preview ·
|
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| lab/NavigationScreen | 74.8 kB (new) · 1.1 kB CSS (new) | — | — · ✓ · ✗ |
| lab/NavigationStack | 74.8 kB (new) · 1.1 kB CSS (new) | — | — · ✓ · ✓ |
Package size: measured; main has no measurement of the merge base to compare yet.
API changes: none in this pull request
Committed API report behind this build: cli 19 rows, core 23 rows; run pnpm api:report.
Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.
Generated by PR Enrichment workflow | View full report
There was a problem hiding this comment.
Approval waits on an engineering owner decision, and several changes are needed whatever that decision is.
The description asks for two decisions that no current spec settles: the NavigationStack, NavigationScreen and useNavigationStack component and API, and playing the stack's springs and delays as measured literals, which AST-094 FR4 forbids today ("MUST NOT play a literal"). An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by updating the governing spec on main, and the author may do so as an engineering owner.
- Reduce the diff to the NavigationStack commit by landing #9399 and #9696 first and rebasing, because 13,882 authored lines exceed the 8,000-line limit for an owner-directed batch.
- Stop the covered screen's bar dimming from showing through the 1 px gap each bar layer leaves (
NavigationStack.tsx:268-272,:382-387), which darkens the bar's bottom rule under its leading 70 % on every pushed screen. - Replace the
focusinlistener Effect (NavigationStack.tsx:1765-1786) with anonFocusCapturehandler on the container, beside its existing capture handlers. - Give the transition Effect (
NavigationStack.tsx:1417-1517) every input it reads, not onlyview.transition.id. - Move the registry and chevron writes out of the
CardLayer,BarLayerandBackControllayout Effects and into the callback refs those elements already have. - Reuse SearchField's
activationTimingandeasedCurveinstead of re-deriving the activation clock and curve innavigationSearch.ts. - Drop the module-level
isStackMotionRegisteredguard, because Core's table registration is already idempotent. - In
SearchFieldTouch.ts, use Core's sharedobserveResizeand share the capture-phase blur reveal refusal with Core'suseMobileKeyboardinstead of copying it. - Type
searchas a SearchField element, so the stack never injectsliftandonActiveChangeinto a DOM element.
[Automated review]
| position: 'absolute', | ||
| insetInline: 0, | ||
| top: 0, | ||
| bottom: 1, |
There was a problem hiding this comment.
This 1 px gap lets the covered bar layer's dimming (the dim style, inset: 0) show through. On a pushed screen at rest, the bar's bottom rule reads darker under the leading 70 % and lighter under the trailing 30 %. End the dimming layers above the hairline, or hide the covered bar layer once a push settles.
| * has no theme surface. Once per page, before the first animation; nothing | ||
| * registers on import. | ||
| */ | ||
| let isStackMotionRegistered = false; |
There was a problem hiding this comment.
Core's registerMotionTable already ignores a second registration of the same table, so this module-level flag adds state for a concern Core owns. Call registerNavigationTable() and registerSpringsTable() directly, as core/src/motion/animatedLayout.ts does.
| } | ||
| }); | ||
| // Only a new transition restarts this; the layers are driven imperatively. | ||
| }, [view.transition.id]); |
There was a problem hiding this comment.
This Effect also reads view.transition, view.models, settle, setProgress, animateProgress, fadeBack, focusAfter, showCoveredBar and the back-button animators. List them, or read the stable ones through refs, so the dependencies match what the setup reads.
|
|
||
| // A field takes focus as it activates: measure its lift in that event, so | ||
| // the commit that activates it hands the field the right distance. | ||
| useEffect(() => { |
There was a problem hiding this comment.
A field's focus is one event. The container already takes onClickCapture and onKeyDownCapture, so an onFocusCapture handler there can measure the lift, and this Effect and its listener can go.
| * role (the UIViewPropertyAnimator curve). SearchField | ||
| * plays the same clock and curve, so the field and the bar move together. | ||
| */ | ||
| export function searchClock(element: Element | null | undefined): SearchClock { |
There was a problem hiding this comment.
searchFieldModel.ts already exports this clock (activationTiming) and curve (easedCurve), read a different way (directly from the table, not through resolveMotion). Call those and delete this copy, so the field and the bar cannot drift apart.
fbc5566 to
e29cdfc
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner decision, and four changes are needed whatever that decision is.
The description asks for two decisions that no current spec settles: the NavigationStack, NavigationScreen and useNavigationStack component and API, and playing the stack's springs and delays as measured values, which AST-094 FR4 forbids today ("MUST NOT play a literal"). An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request or by updating the governing spec on main, and the author may do so as an engineering owner.
- Reduce the diff to the NavigationStack commit by landing #9399 and #9696 first and rebasing, because 14,016 authored lines exceed the 8,000-line limit for an owner-directed batch.
- Move the focus after a push or pop (
NavigationStack.tsx:1361-1414) onto Core'sfocusForLastInput,isFocusLostand shared modality tracker, re-exported through./internal/touch, instead of the stack's ownisKeyboardandisFocusVisiblerecord. - Move
BarLayer'stitleTextregistry write (NavigationStack.tsx:680-682) out of its layout Effect and into thetitleRefscallback ref. - Remove the sentence in
NavigationStack.doc.mjsthat promises a pushing list row stays selected and deselects on pop, or implement and test that behaviour.
The earlier bar-rule, Effect, search-clock and search typing findings are fixed.
[Automated review]
| const isKeyboard = | ||
| lastActivationRef.current?.isKeyboard === true || | ||
| isFocusVisible(active); | ||
| const focusOptions: FocusOptions & {focusVisible?: boolean} = { | ||
| preventScroll: true, | ||
| ...(isKeyboard ? null : {focusVisible: false}), | ||
| }; |
There was a problem hiding this comment.
The ring here follows the stack's own modality record (isKeyboard from rememberActivation, and isFocusVisible). Core's focusForLastInput in utils/focusReturn.ts, over useInteractionModalityTracking, already does this move (Dialog calls it), and architecture:interaction-modality INV2 says shared modality decides visibility. Re-export it, isFocusLost and the tracker through ./internal/touch and call focusForLastInput(target, {preventScroll: true}).
| const entry = registry.get(model.key) ?? {}; | ||
| entry.titleText = titleText.animator; | ||
| registry.set(model.key, entry); |
There was a problem hiding this comment.
This registry write is attach work. titleRefs below already writes the title element into the same entry, so entry.titleText = titleText.animator can move there, and this Effect keeps only the title's motion.
| name: 'NavigationStack', | ||
| displayName: 'Navigation Stack', | ||
| description: | ||
| "The navigator. You own the path: pass the entries pushed on the root screen and update them in onPathChange. The stack renders the root screen and one screen per entry, the top one last, keeps covered screens and their scroll positions alive, and makes them inert. The bar shows the top screen's title, its trailing items and a round Back button with a chevron, named by the screen it returns to. A list row that pushes a screen stays selected under it and deselects as the screen pops, following a back swipe. A push moves focus to the new screen's title; a pop returns it to the control that pushed the screen.", |
There was a problem hiding this comment.
"A list row that pushes a screen stays selected under it and deselects as the screen pops, following a back swipe." Nothing in the stack or in Core's List selects or deselects a row, and no test covers it. Delete the sentence, or implement and test it.
| /** | ||
| * A push/pop navigator: UINavigationController's transitions, back button | ||
| * and back swipes, on the compositor. | ||
| */ | ||
| /** | ||
| * Registers the navigation and spring tables the stack animates with in | ||
| * Core's motion registry, where a role plays its table value: touch motion | ||
| * has no theme surface. Once per page, before the first animation; nothing | ||
| * registers on import. | ||
| */ |
There was a problem hiding this comment.
Non-blocking: these two JSDoc blocks now sit above isTouchNow's. The first belongs on NavigationStack below, which has none; the second describes the deleted registerStackMotion. Move the first and delete the second.
Lab's NavigationStack, which follows in its own pull request, reads the screen-edge pan recognizer, the spring solver, the motion resolver, UIKit's navigation table and its four navigation springs. It also reads Core's focus helpers that follow the last input. Core's internal `@astryxdesign/core/internal/touch` entry now names them: named re-exports only, outside the public API. The entry test checks each runtime name is the value Core imports by path. The touch-support bundle test checks each is in the internal entry and not in the root entry. The stack's back button falls back to one catalog key, `@astryx.navigationStack.back`, added to Core's English catalog as the Comments family's Lab keys are.
e29cdfc to
13a2693
Compare
|
/approve-spec 13a2693 |
There was a problem hiding this comment.
Approval of this stack waits on an engineering owner's decision for #9697, which also needs two fixes whatever that decision is.
#9696
Approved.
#9697
On hold: no current spec covers the NavigationStack, NavigationScreen and useNavigationStack API (including whether isInteractivePopEnabled stays a boolean), and the stack plays measured springs that are not motion roles, which AST-094 FR4 and FR45 do not allow today, so an engineering owner needs to approve the API and decide whether NavigationStack is a gesture component whose springs join the motion role table or a tap component whose push, pop and Back play the theme's curve tokens, by commenting /approve-spec <full head SHA> on this pull request or updating the governing spec on main (the author may do so as an engineering owner).
- Miss: each screen's scroller measures its own overflow and, on a screen of plain text, leaves keyboard users only a stop Chromium adds by itself, with no visible focus indicator and no name (
NavigationStack.tsx:279-299,:409-429), so put Core'suseScrollableAreaon the scroller and its content root (named by the screen's title) and remove the stack's own detector andoutline: 'none'. - Newly live check: the story's row chevrons (
NavigationStack.stories.tsx:50) do not mirror under right-to-left and point the same way as Back, so addxstyle={rtlStyles.mirror}asList.stories.tsxdoes.
Whole stack
The two pull requests fit together with no leftovers, and a pull request lands only when it and every pull request below it are approved, so #9697 waits on the owner decision and both fixes.
[Automated review]
| useLayoutEffect(() => { | ||
| const element = scrollerRef.current; | ||
| if (element == null) { | ||
| return undefined; | ||
| } | ||
| // Through core's shared observer, which also measures once right away. | ||
| const measure = () => | ||
| setIsOverflowing(element.scrollHeight > element.clientHeight + 1); | ||
| const unobserveScroller = observeResize(element, measure); | ||
| const inner = element.lastElementChild; | ||
| const unobserveContent = | ||
| inner == null ? undefined : observeResize(inner, measure); | ||
| return () => { | ||
| unobserveScroller(); | ||
| unobserveContent?.(); | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
Miss: this Effect is a second overflow detector beside Core's useScrollableArea, which already contains overscroll only on the overflowing axis and gives the viewport FR12's named keyboard stop (spec:AST-025 IR1, FR12). On a screen of plain text the scroller is reachable only because Chromium makes it focusable, with no focus indicator (outline: 'none' above) and no name. Put the viewport and content props of useScrollableArea({axis: 'block', overscroll: 'contain', keyboardAccess: {owner: 'contentOrViewport', label: title}}) on the scroller and its content root, then delete this Effect and scrollerOverflowing.
| <ListItem | ||
| key={row} | ||
| label={row} | ||
| endContent={<Icon icon="chevronRight" color="secondary" />} |
There was a problem hiding this comment.
Newly live check: this drill-in chevron does not mirror under right-to-left, so it points the same way as Back's mirrored chevron (DR4). Add xstyle={rtlStyles.mirror} from @astryxdesign/core/utils, as List.stories.tsx:535 does.
| * - top card: x = p · W; | ||
| * - card under it: x = parallax · W · (1 − p), so from −0.3 W to 0; | ||
| * - dimming over the card under it: opacity 1 − p (black at 10 %); | ||
| * - bar contents: the top screen's at 1 − p, the one under it at p. |
There was a problem hiding this comment.
Non-blocking (INV29): each bar moves with its card, and under a finger the revealed bar holds still (NavigationStack.tsx:900-903). Suggested: "- bars: each moves with its screen's card. Under a finger, the revealed bar holds still."
13a2693 to
74d7daa
Compare
|
/approve-spec 74d7daa |
There was a problem hiding this comment.
Approval of this stack waits on an engineering owner recording the #9697 decision again at its current head; no code changes are needed.
#9696
Approved.
#9697
On hold: an engineering owner approved the NavigationStack, NavigationScreen and useNavigationStack API and its measured springs at the previous head, but this head amends that commit, so the approval no longer applies. No current spec covers that API (including whether isInteractivePopEnabled stays a boolean or becomes a <thing>Behavior prop), and the stack plays springs that are not motion roles, which AST-094 FR4 and FR45 do not allow today. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, or by updating the governing spec on main; the author may do so as an engineering owner.
The screens' keyboard path and the story's right-to-left chevrons are fixed. Three comment suggestions are left inline as non-blocking.
Whole stack
The two pull requests fit together with no leftovers. A pull request lands only when it and every pull request below it are approved, so #9697 waits on that decision.
[Automated review]
| /** | ||
| * @file NavigationStackTouch.ts | ||
| * @input Core's gesture recognizers and UIKit gesture table, from its | ||
| * internal touch entry, and navigationMotion.ts and navigationTransition.ts | ||
| * for the swipe's values and release math | ||
| * @output useBackSwipes: UIKit's two back swipes, from the leading screen | ||
| * edge and from anywhere in the content (iOS 26), driving a stack through | ||
| * BackSwipeStack | ||
| * @position NavigationStack's back swipes, imported in one place | ||
| * (NavigationStack.tsx). | ||
| * | ||
| * The content swipe answers every pointer, a finger, a pen and a mouse drag | ||
| * alike. The edge swipe takes a finger only, as the screen-edge pan does by | ||
| * default. The stack tracks a swipe 1:1 and releases it by UIKit's rule. The | ||
| * swipe finishes when progress plus 0.35 s of the pan's release velocity | ||
| * passes half. A release moving back cancels. | ||
| * | ||
| * In a browser tab the screen's leading edge is the browser's own back | ||
| * swipe (iOS Safari keeps it). So the edge swipe only runs in a home-screen | ||
| * web app (`navigator.standalone`). In a tab the content swipe ignores a | ||
| * finger that starts in that edge band. Server render and first paint | ||
| * assume a tab. | ||
| */ |
There was a problem hiding this comment.
Non-blocking (INV29): this header is 23 lines, and its two paragraphs repeat NavigationStack's JSDoc (NavigationStack.tsx:661-706). Keep @input, @output and @position.
| // The screen's scroller contains its overscroll only while it overflows. | ||
| // While it overflows it is a keyboard stop named by the title. A forward | ||
| // Tab into it moves on to its first plain link or button. |
There was a problem hiding this comment.
Non-blocking (INV29): the first two sentences restate the hook's options. Suggested: "A forward Tab into the scroller moves on to its first plain link or button."
| // A screen's scroller is focusable while it overflows, but it is not a | ||
| // control: a press on its plain content returns focus to the title. |
There was a problem hiding this comment.
Non-blocking (INV29): "a press on its plain content returns focus to the title" reads as if the press moves focus. Suggested: "The scroller is focusable while it overflows, but it is not a control. After a push from its plain content, the pop focuses the title."
74d7daa to
a3726e1
Compare
|
/approve-spec a3726e1 |
There was a problem hiding this comment.
Approval of this stack waits on an engineering owner recording the #9697 decision at its current head, and #9697 needs one code change either way.
#9696
Approved.
#9697
On hold, with one required change:
- Miss: the large-title scroll listener in
NavigationStack.tsx(lines 382–403) is attached in a layout Effect, but the title's existing callback reflargeTitleCallbackcan attach it and return its cleanup, so move it there and delete the Effect andlargeTitleRef.
An engineering owner approved the NavigationStack, NavigationScreen and useNavigationStack API and its measured springs at the previous head, but this head amends that commit with comment edits only, so the approval no longer applies; no current spec covers that API (including whether isInteractivePopEnabled stays a boolean or becomes a <thing>Behavior prop), and the springs are not the motion roles AST-094 FR4 and FR45 require, so a theme's tempo does not reach them. An engineering owner records the decision by commenting /approve-spec <full head SHA> on the head that carries the fix, or by updating the governing spec on main; the author may do so as an engineering owner.
Whole stack
The two pull requests fit together with no leftovers and merge cleanly onto main; a pull request lands only when it and every pull request below it are approved.
[Automated review]
| useLayoutEffect(() => { | ||
| const title = largeTitleRef.current; | ||
| const scroller = title?.parentElement ?? null; | ||
| if (!isLarge || title == null || scroller == null) { | ||
| return undefined; | ||
| } | ||
| // The screen scrolls under the bar: the title has gone once its bottom | ||
| // passes the bar's. A title with no layout has not scrolled away. | ||
| const read = () => | ||
| onLargeTitleCollapse( | ||
| model.key, | ||
| title.offsetHeight > 0 && | ||
| scroller.scrollTop >= | ||
| title.offsetTop + title.offsetHeight - BAR_HEIGHT, | ||
| ); | ||
| read(); | ||
| scroller.addEventListener('scroll', read, {passive: true}); | ||
| return () => { | ||
| scroller.removeEventListener('scroll', read); | ||
| onLargeTitleCollapse(model.key, false); | ||
| }; | ||
| }, [isLarge, model.key, onLargeTitleCollapse]); |
There was a problem hiding this comment.
Miss: this Effect attaches the scroller's scroll listener once the large title's <h1> exists (it reads largeTitleRef.current, which largeTitleCallback sets). That is work caused by the title attaching, so the title's callback ref can own it. Attach the listener and run the first read in largeTitleCallback, return a cleanup that removes it and calls onLargeTitleCollapse(model.key, false) (as containerCallback returns one), and delete this Effect and largeTitleRef.
…igationController NavigationStack renders the app's path, one NavigationScreen per entry, with UIKit's push spring, the covered screen's parallax and dimming, a bar per screen that rides with it, and back swipes from the leading edge and from anywhere in the content, tracked 1:1 and finished by UIKit's release rule. Large titles hand over to the bar's title as the list scrolls. The content swipe answers every pointer, and a moved focus shows its ring by the last input, through Core's shared modality. The values only this stack reads live beside it in navigationMotion.ts, each with its measurement; Core's UIKit tables are read through its internal touch entry. astryx discover lists the two components, and the route lock gains their pages.
a3726e1 to
2317228
Compare
|
/approve-spec 2317228 |
There was a problem hiding this comment.
Approved pending CI: CI has not run on this head yet, because it is a draft from a fork; once it is marked ready for review, a maintainer adds the ci:run label so CI runs.
#9696
Approved.
#9697
Approved: an engineering owner recorded the decision on the NavigationStack API, the isInteractivePopEnabled boolean and the measured springs at this head, and the large-title scroll listener now lives in the title's callback ref, which resolves the last review's required change (one optional comment-wording suggestion is inline).
Whole stack
The two pull requests fit together and merge cleanly onto main, and a pull request lands only when it and every pull request below it are approved.
[Automated review]
| // The large title has collapsed once it has scrolled entirely under the | ||
| // bar: the bar's own title takes over. The title reads the card's scroll | ||
| // position from the moment it attaches. A card hidden two levels down | ||
| // scrolls no more and keeps its last value. A title that detaches drops its | ||
| // collapse. One that attaches reads the scroll position again. |
There was a problem hiding this comment.
Non-blocking: two sentences here restate what read() and addEventListener already show. A shorter comment keeps only the reasons:
// The large title has collapsed once it has scrolled entirely under the
// bar; the bar's title then takes over. A card hidden two levels down
// scrolls no more and keeps its last value. A title that detaches drops its
// collapse, so a screen pushed again starts with its large title.|
/approve-spec 2c3f13d |
There was a problem hiding this comment.
Approved, and CI passes on this head.
#9696
Approved. Merging main into it leaves its change as it was.
#9697
Approved: an engineering owner recorded the decision on the NavigationStack API, the isInteractivePopEnabled boolean and the measured springs at this head, and merging main and the updated #9696 leaves the change as it was. The earlier optional comment-wording suggestion on the large-title callback still applies.
Whole stack
The two pull requests fit together and are up to date with main. A pull request lands only when it and every pull request below it are approved.
[Automated review]
|
/approve-spec 30a67bb |
There was a problem hiding this comment.
Approved pending CI: CI is still running on this head.
#9696
Approved, and already merged.
#9697
Approved: an engineering owner recorded the decision on the NavigationStack, NavigationScreen and useNavigationStack API, the isInteractivePopEnabled boolean and the measured springs at this head, and merging main leaves the change as it was. One optional suggestion remains: the comment above largeTitleCallback can keep only its reasons (see the inline comment).
Whole stack
The stack passes as a whole. A pull request in the stack lands only when it and every pull request below it are approved; #9696 is already in main.
[Automated review]
| // The large title has collapsed once it has scrolled entirely under the | ||
| // bar: the bar's own title takes over. The title reads the card's scroll | ||
| // position from the moment it attaches. A card hidden two levels down | ||
| // scrolls no more and keeps its last value. A title that detaches drops its | ||
| // collapse. One that attaches reads the scroll position again. |
There was a problem hiding this comment.
Optional (non-blocking): two sentences here restate what read() and addEventListener already show. A shorter comment keeps only the reasons:
// The large title has collapsed once it has scrolled entirely under the
// bar; the bar's title then takes over. A card hidden two levels down
// scrolls no more and keeps its last value. A title that detaches drops
// its collapse, so a screen pushed again starts with its large title.
Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)
Stack: #9696 → #9697
Stacked on #9696 (Core's part); the diff shows its files until it merges.
User need
Phone layouts need iOS's drill-down navigation: screens push, a back swipe follows the finger, and large titles hand over to the bar.
Problem and solution fit
NavigationStackrenders the app'spath, oneNavigationScreenper entry. Covered screens stay mounted but inert.titleDisplayMode="large": the large title scrolls under the bar, which then shows its own title.endContentputs content at the end of the screen's bar.Current specification
Lab is not shipped; no spec needed. It follows
spec:AST-094FR3 (UIKit's rubber band), FR6 (a caught push continues from the screen), FR22 (a tab's edge swipe stays the browser's), FR36–FR37 (one decision; moving back cancels) and FR43 (every pointer);spec:AST-100FR2–FR3 (the pan's release velocity).astryx discoverlists both components, sinceofficial-packages.jsonis regenerated (spec:AST-051FR11).Owner rule, Nov 4 2026 (Cindy): anything Lab-only gets an OK. Open until promotion to Core:
NavigationStack,NavigationScreen,useNavigationStack.spec:AST-094FR4): the stack plays its springs directly, not through roles. Motion class per AST-094 FR45 (gesture class: the interactive edge-swipe pop is drag-capable, so pushes and pops spring, tap-started ones included); Lab only; confirmed with the owner at promotion.Public delta and caller burden
Lab only: three exports and their prop types. The discover list and the docsite route lock gain two pages.
Design and alternatives
One progress value drives every layer on Core's compositor animator, so a push and a swipe share one model and survive a busy main thread. View Transitions can't track a finger.
Three layout Effects copy the view, path and swipe stack into refs the transition Effect and gestures read; React 19.0, Lab's floor, lacks
useEffectEvent.Size: about 4,940 added lines in one Lab component: the component (~2,020), real-touch browser proofs (~1,960), unit tests (~480), stories, docs, lists (~490).
Evidence
Recordings on the iOS 26.2 simulator, Chromium and the Android 15 emulator: the index.
Scope
Testing
Chrome's device toolbar, "Lab/NavigationStack": tap General; swipe back short of half (springs back), then past it (pops). Scroll "Large title".
Local gates at e3f2a34, which differs from this head (2317228) only in one comment (on #9696 66e6c3b over main 9e559e9), after a frozen install (exit 0): root build exit 0;
pnpm typecheckexit 0; the Storybook build exit 0, stamped with that head as CI stamps it; the NavigationStack interaction proofs 43/43 in Chromium and 39 passed + 4 skipped in WebKit (the skips are the three main-thread-block proofs, a stated WebKit gap, and the Chromium-only rendering trace); Lab's UI units 967/967 (86 files), NavigationStack's own 37 among them; the touch-support bundle test 11/11; the PR accessibility contracts 1,182 passed, 1 skipped and 1 failed: Core's Link new-tab release contract, red once under the full three-worker run, 40/40 alone, and outside this diff's reach (reported to its owner);check:changesets, changeset coverage,check:knowledgeandcheck:repopass; strict lint and Prettier clean on the changed files;pnpm review:prewith this body reports only the three ref-copy Effects argued above. It merges clean onto #9696 and onto main, with no old motion-engine names.Supersedes #8107, #8108, #8185 and #8699. #8204 (search in the bar) is withdrawn with SearchField.