From cd87192ce8563db92421c29d7ed63f48ee3f9a36 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:07:51 -0700 Subject: [PATCH 1/7] feat(sandbox): the motion lab previews TopNav's mega-menu exit with the missing Reduce Motion branch The live previews page gets a TopNav mega-menu card: core's real TopNav, opened and closed on a loop through its own trigger, beside the same component carrying the reduced-motion branch it lacks as a sandbox prototype in CSS. Under Reduce Motion (or the lab's Degrade) the panel only fades, within 200 ms; under Delete it cuts. The panes open half a loop apart so their auto popovers don't close each other. An enter-and-exit slider tunes the duration core reads. Core's TopNav is unchanged; the card notes that animator retargeting lands with useAnimator. Co-authored-by: Ernest --- .../(sandbox)/pages/motion-lab/LabDemos.tsx | 87 +++++++++++++++++++ .../pages/motion-lab/MotionLab.module.css | 24 +++++ .../pages/motion-lab/previews/page.tsx | 34 ++++++++ 3 files changed, 145 insertions(+) diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx index 8738bab87e88b..c0e089e1a7524 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx @@ -40,6 +40,12 @@ import { } from './proposed/TabList'; import {Skeleton} from '@astryxdesign/core/Skeleton'; import {Token} from '@astryxdesign/core/Token'; +import { + TopNav, + TopNavHeading, + TopNavMegaMenu, + TopNavMegaMenuItem, +} from '@astryxdesign/core/TopNav'; import {HStack, VStack} from '@astryxdesign/core/Layout'; import {Text} from '@astryxdesign/core/Text'; import {useMotionLab} from './MotionLabStore'; @@ -897,6 +903,87 @@ export function OriginRig({kind}: {kind: 'zero' | 'centre' | 'anchored'}) { ); } +// --- TopNav mega-menu exit ---------------------------------------------------- + +const MEGA_MENU_LOOP_MS = 3200; +const MEGA_MENU_OPEN_MS = 1200; + +/** + * The real TopNav mega-menu, opened and closed on a loop through its own + * trigger. Before is core as it ships: the panel rises 4px and fades on open + * and close, and does the same under Reduce Motion. After is the same + * component with the missing branch as a sandbox prototype + * (MotionLab.module.css `.megaMenuProposed`): under Reduce Motion, or the + * lab's Degrade, the panel only fades, within 200 ms; under Delete it cuts. + * The two panes open half a loop apart: opening one auto popover closes any + * other, so in step they would close each other. + */ +export function TopNavExitRig({mode}: {mode: Mode}) { + const {reducedMotion} = useMotionLab(); + const rootRef = useRef(null); + const timersRef = useRef([]); + const setOpen = useCallback((isOpen: boolean) => { + const trigger = rootRef.current?.querySelector( + 'button[aria-expanded]', + ); + if (trigger != null && (trigger.ariaExpanded === 'true') !== isOpen) { + trigger.click(); + } + }, []); + useLoop(() => { + timersRef.current.forEach(window.clearTimeout); + const offset = mode === 'before' ? 0 : MEGA_MENU_LOOP_MS / 2; + timersRef.current = [ + window.setTimeout(() => setOpen(true), offset), + window.setTimeout(() => setOpen(false), offset + MEGA_MENU_OPEN_MS), + ]; + }, MEGA_MENU_LOOP_MS); + useEffect(() => () => timersRef.current.forEach(window.clearTimeout), []); + + return ( + +
+ } + startContent={ + + + + + + } + /> + } + /> +
+ + {mode === 'before' + ? 'The only popover exit in core, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.' + : 'The same exit with the missing branch: under Reduce Motion (or the lab\u2019s Degrade) the panel only fades, within 200 ms; under Delete it cuts. Turn either on to compare.'} + +
+ ); +} + // --- loops ------------------------------------------------------------------- export function LoopRig({policy}: {policy: 'today' | 'delete' | 'degrade'}) { diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css index 382edee8ac3f5..66824c6e3f9d4 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css @@ -753,6 +753,30 @@ } } +/* ------------------------------------------------- TopNav mega-menu ---- */ + +/* A sandbox prototype of the reduced-motion branch TopNav's mega-menu panel + lacks: under Reduce Motion it drops the 4px rise and only fades, no longer + than the 200 ms cross-fade (design:reduced-motion DR2). The panel stays a + DOM descendant in the top layer, so the scope reaches it. The lab's + Degrade applies the same branch; Delete cuts. */ +@media (prefers-reduced-motion: reduce) { + .megaMenuProposed [popover] { + transform: none !important; + transition-duration: min(var(--duration-medium-min), 200ms) !important; + } +} + +.megaMenuProposed[data-reduced-motion='degrade'] [popover] { + transform: none !important; + transition-duration: min(var(--duration-medium-min), 200ms) !important; +} + +.megaMenuProposed[data-reduced-motion='delete'] [popover] { + transform: none !important; + transition-duration: 0s !important; +} + /* ---------------------------------------------------------------- tint ---- */ /* The Table hover tint, at whatever duration and curve is being argued over. diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx index 593cf55a52701..195add97fbdb3 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx @@ -30,6 +30,7 @@ import { SkeletonSwapRig, StatCountRig, TabIndicatorRig, + TopNavExitRig, } from '../LabDemos'; export default function PreviewsPage() { @@ -196,6 +197,39 @@ export default function PreviewsPage() { + }> + , + }, + { + tone: 'after', + label: 'Proposed — Reduce Motion only fades', + content: , + }, + ]} + /> + + + + Both panes are core’s real TopNav. The right one + carries the missing reduced-motion branch as a sandbox prototype in + CSS. Animator retargeting lands with useAnimator{' '} + (public, AST-107, #8600). + + + + Date: Mon, 26 Oct 2026 16:45:56 -0700 Subject: [PATCH 2/7] fix(sandbox): the TopNav card drops a slider that tuned nothing The lab's sliders tune its semantic tokens, and TopNav's panel reads the primitive duration, so the enter-and-exit slider read 0 ms and moved nothing. Co-authored-by: Ernest --- .../src/app/(sandbox)/pages/motion-lab/previews/page.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx index 195add97fbdb3..fa5f458b4188d 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx @@ -216,11 +216,6 @@ export default function PreviewsPage() { ]} /> - Both panes are core’s real TopNav. The right one carries the missing reduced-motion branch as a sandbox prototype in From e83fab931b19da89206aaacf3bf754ca93bba885 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 17:22:03 -0700 Subject: [PATCH 3/7] fix(sandbox): the TopNav loop clicks like a pointer and keeps focus, and its branch changes at once with a mouse The loop's click() sent detail 0, which the menu reads as Enter or Space, so each open moved focus into the panel and the scheduled close only refocused it. It now dispatches a pointer-style click (detail 1) and puts focus back where it was. The prototype's Reduce Motion branch fades only under a coarse pointer and changes at once with a fine one (design:reduced-motion DR2, DR3); the pane label and hint say so, and the card words useAnimator as proposed (AST-107 is an open spec PR). Co-authored-by: Ernest --- .../(sandbox)/pages/motion-lab/LabDemos.tsx | 27 +++++++++++++++---- .../pages/motion-lab/MotionLab.module.css | 18 +++++++++---- .../pages/motion-lab/previews/page.tsx | 6 ++--- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx index c0e089e1a7524..e52431bb7c8b7 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx @@ -913,8 +913,10 @@ const MEGA_MENU_OPEN_MS = 1200; * trigger. Before is core as it ships: the panel rises 4px and fades on open * and close, and does the same under Reduce Motion. After is the same * component with the missing branch as a sandbox prototype - * (MotionLab.module.css `.megaMenuProposed`): under Reduce Motion, or the - * lab's Degrade, the panel only fades, within 200 ms; under Delete it cuts. + * (MotionLab.module.css `.megaMenuProposed`): under Reduce Motion the panel + * fades within 200 ms on a touch screen and changes at once with a mouse + * (design:reduced-motion DR2, DR3); the lab's Degrade shows the fade, its + * Delete the cut. * The two panes open half a loop apart: opening one auto popover closes any * other, so in step they would close each other. */ @@ -926,8 +928,23 @@ export function TopNavExitRig({mode}: {mode: Mode}) { const trigger = rootRef.current?.querySelector( 'button[aria-expanded]', ); - if (trigger != null && (trigger.ariaExpanded === 'true') !== isOpen) { - trigger.click(); + if (trigger == null || (trigger.ariaExpanded === 'true') === isOpen) { + return; + } + // A pointer-style click (detail 1): `click()` sends detail 0, which the + // menu reads as Enter or Space and answers by moving focus into the + // panel. Focus stays wherever the person left it. + const focused = document.activeElement; + trigger.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + view: window, + }), + ); + if (document.activeElement !== focused && focused instanceof HTMLElement) { + focused.focus({preventScroll: true}); } }, []); useLoop(() => { @@ -978,7 +995,7 @@ export function TopNavExitRig({mode}: {mode: Mode}) { {mode === 'before' ? 'The only popover exit in core, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.' - : 'The same exit with the missing branch: under Reduce Motion (or the lab\u2019s Degrade) the panel only fades, within 200 ms; under Delete it cuts. Turn either on to compare.'} + : 'The same exit with the missing branch: under Reduce Motion the panel fades within 200 ms on a touch screen and changes at once with a mouse; the lab\u2019s Degrade shows the fade, Delete the cut. Turn either on to compare.'} ); diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css index 66824c6e3f9d4..6a68c91986276 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/MotionLab.module.css @@ -756,17 +756,25 @@ /* ------------------------------------------------- TopNav mega-menu ---- */ /* A sandbox prototype of the reduced-motion branch TopNav's mega-menu panel - lacks: under Reduce Motion it drops the 4px rise and only fades, no longer - than the 200 ms cross-fade (design:reduced-motion DR2). The panel stays a - DOM descendant in the top layer, so the scope reaches it. The lab's - Degrade applies the same branch; Delete cuts. */ -@media (prefers-reduced-motion: reduce) { + lacks. Under Reduce Motion it never rises: on a touch screen it only + fades, no longer than the 200 ms cross-fade, and with a mouse it changes + at once (design:reduced-motion DR1-DR3). The panel stays a DOM descendant + in the top layer, so the scope reaches it. The lab's Degrade shows the + fade; Delete cuts. */ +@media (prefers-reduced-motion: reduce) and (pointer: coarse) { .megaMenuProposed [popover] { transform: none !important; transition-duration: min(var(--duration-medium-min), 200ms) !important; } } +@media (prefers-reduced-motion: reduce) and (not (pointer: coarse)) { + .megaMenuProposed [popover] { + transform: none !important; + transition-duration: 0s !important; + } +} + .megaMenuProposed[data-reduced-motion='degrade'] [popover] { transform: none !important; transition-duration: min(var(--duration-medium-min), 200ms) !important; diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx index fa5f458b4188d..0ea49bf6d8d96 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx @@ -210,7 +210,7 @@ export default function PreviewsPage() { }, { tone: 'after', - label: 'Proposed — Reduce Motion only fades', + label: 'Proposed — Reduce Motion never rises', content: , }, ]} @@ -219,8 +219,8 @@ export default function PreviewsPage() { Both panes are core’s real TopNav. The right one carries the missing reduced-motion branch as a sandbox prototype in - CSS. Animator retargeting lands with useAnimator{' '} - (public, AST-107, #8600). + CSS. Animator retargeting would come with the proposed public{' '} + useAnimator (AST-107, spec PR #8600). From 9c733f73ceac461902f431516f5997d9b9ad12d8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 17:38:49 -0700 Subject: [PATCH 4/7] fix(sandbox): the TopNav loop leaves nothing focused when nothing was Restoring focus to the body is a no-op, so after a loop click the trigger kept the focus the menu gave it. When nothing had focus, the loop now blurs whatever the click focused. Co-authored-by: Ernest From 0c058e403f22128d2677d7f5f9096be49ee012b1 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 17:39:12 -0700 Subject: [PATCH 5/7] fix(sandbox): the TopNav loop blurs what its click focused when nothing had focus The previous commit's message described this change, but its edit didn't apply; this commit carries it. Restoring focus to the body is a no-op, so the trigger kept the focus the menu gave it. Co-authored-by: Ernest --- .../src/app/(sandbox)/pages/motion-lab/LabDemos.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx index e52431bb7c8b7..739ebbd1c4506 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx @@ -943,8 +943,15 @@ export function TopNavExitRig({mode}: {mode: Mode}) { view: window, }), ); - if (document.activeElement !== focused && focused instanceof HTMLElement) { + const moved = document.activeElement; + if (moved === focused || !(moved instanceof HTMLElement)) { + return; + } + if (focused instanceof HTMLElement && focused !== document.body) { focused.focus({preventScroll: true}); + } else { + // Nothing had focus: leave nothing focused. + moved.blur(); } }, []); useLoop(() => { From 94f34bd28e62080889e29070e8302a5b51c303bd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 18:56:13 -0700 Subject: [PATCH 6/7] fix(sandbox): the TopNav card says its exit is the only one in core that travels, and the plan lists it as built Core has a second popover exit, the keyboard hint's 150 ms fade, so the card, its Today hint and the preview plan now say TopNav's is the only popover exit that travels. The plan's TopNav row is built, on TopNavExitRig. Co-authored-by: Ernest --- .../sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx | 2 +- .../src/app/(sandbox)/pages/motion-lab/previewPlan.ts | 6 +++--- .../src/app/(sandbox)/pages/motion-lab/previews/page.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx index 739ebbd1c4506..d58a067f09f61 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx @@ -1001,7 +1001,7 @@ export function TopNavExitRig({mode}: {mode: Mode}) { {mode === 'before' - ? 'The only popover exit in core, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.' + ? 'The only popover exit in core that travels, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.' : 'The same exit with the missing branch: under Reduce Motion the panel fades within 200 ms on a touch screen and changes at once with a mouse; the lab\u2019s Degrade shows the fade, Delete the cut. Turn either on to compare.'} diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts index a46433a4a131d..c86a8bbf16085 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts @@ -571,7 +571,7 @@ export const PREVIEW_PLAN: ReadonlyArray = [ motionCost: 'idle', dirs: ['TopNav'], today: 'Mega-menu with a real exit, sub-lists, chevrons.', - gap: 'The only component animating a popover exit, and it never checks reduced motion.', + gap: 'The only popover exit in core that travels, and it never checks reduced motion.', shows: 'Its exit as the existing proof the pattern works, plus the missing reduced-motion branch.', hardCase: @@ -582,8 +582,8 @@ export const PREVIEW_PLAN: ReadonlyArray = [ 'reduced motion', 'traverse speed', ], - status: 'specified', - rig: null, + status: 'built', + rig: 'TopNavExitRig', priority: 1, measurementNote: null, }, diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx index 0ea49bf6d8d96..391b93c3dd81c 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx @@ -199,7 +199,7 @@ export default function PreviewsPage() { }> Date: Mon, 26 Oct 2026 19:39:35 -0700 Subject: [PATCH 7/7] fix(sandbox): the TopNav card claims no uniqueness for its exit A non-modal Drawer's popover exit slides too, so the card, its Today hint and the plan row describe TopNav's anchored panel exit without calling it the only one. Co-authored-by: Ernest --- apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx | 2 +- apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts | 2 +- .../src/app/(sandbox)/pages/motion-lab/previews/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx index d58a067f09f61..1162b4dcc981c 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx @@ -1001,7 +1001,7 @@ export function TopNavExitRig({mode}: {mode: Mode}) { {mode === 'before' - ? 'The only popover exit in core that travels, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.' + ? 'An exit that travels, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.' : 'The same exit with the missing branch: under Reduce Motion the panel fades within 200 ms on a touch screen and changes at once with a mouse; the lab\u2019s Degrade shows the fade, Delete the cut. Turn either on to compare.'} diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts index c86a8bbf16085..d87f804111edf 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previewPlan.ts @@ -571,7 +571,7 @@ export const PREVIEW_PLAN: ReadonlyArray = [ motionCost: 'idle', dirs: ['TopNav'], today: 'Mega-menu with a real exit, sub-lists, chevrons.', - gap: 'The only popover exit in core that travels, and it never checks reduced motion.', + gap: 'Its panel exit travels, and it never checks reduced motion.', shows: 'Its exit as the existing proof the pattern works, plus the missing reduced-motion branch.', hardCase: diff --git a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx index 391b93c3dd81c..6c096bc531f2f 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/motion-lab/previews/page.tsx @@ -199,7 +199,7 @@ export default function PreviewsPage() { }>