diff --git a/docs/specs/AST-105-animated-layout/spec.md b/docs/specs/AST-105-animated-layout/spec.md index cb4addf33b2e5..71e4c87f05a85 100644 --- a/docs/specs/AST-105-animated-layout/spec.md +++ b/docs/specs/AST-105-animated-layout/spec.md @@ -32,8 +32,8 @@ affects_consumer_docs: ## Intent When something inside an area changes size, the changed element and the -content after it animate together on one shared motion role instead of -jumping. An app opts in once per area; without it, nothing changes, except where a +content after it animate together on one shared motion instead of jumping, +timed by the theme's `character` and `tempo`. An app opts in once per area; without it, nothing changes, except where a component's record animates its own change (FR2). ## Non-goals @@ -85,13 +85,17 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); of it opts in. Several changes in one area, nested or not, compose: each element moves once, by its total change. The area never scrolls because of the glide. -- **FR4 — One shared motion.** The changed element plays its own animation, - entering, exiting, or resizing, and the content after it moves with it. - Both read the same motion role, `springs.settle` (`spec:AST-094` - FR4), so the timing is governed centrally, never per component; both start - on the same frame, and Reduce Motion reaches both through that role, as - `design:reduced-motion` DR1–DR3 require: a cross-fade under a coarse - primary pointer, an immediate change otherwise. +- **FR4 — One shared motion that follows the theme.** The changed element + plays its own animation, entering, exiting, or resizing, and the content + after it moves with it. Both play the same motion, so the timing is + governed centrally, never per component: the theme's move curve, + `--ease-move`, over `--duration-medium-min`, which follow the theme's + `character` and `tempo` (`spec:AST-108` FR1, FR2). Under + `character: 'native'`, both play the measured `springs.settle` role + (`spec:AST-094` FR4) at the theme's `tempo` instead. Both start on the same + frame, and Reduce Motion reaches both, as `design:reduced-motion` DR1–DR3 + require: a cross-fade under a coarse primary pointer, an immediate change + otherwise. - **FR5 — Every pointer.** The glide applies under every pointer, a mouse included: it removes a layout jump that people using a fine pointer see too, so it is a named fine-pointer change under `spec:AST-017` FR59, and @@ -104,7 +108,8 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); - **FR7 — Documentation.** The hook's documentation reads: "Returns a handle to pass to a component whose size changes, such as TreeList or Collapsible. When that component changes size, it and the content after it inside - `containerRef` animate together on one shared motion role. Fixed and stuck + `containerRef` animate together on one shared motion that follows the + theme's character and tempo. Fixed and stuck sticky elements stay put, the glide never scrolls the area, and Reduce Motion is respected." - **FR8 — Two elements can swap in place.** An area can swap one element for @@ -133,7 +138,7 @@ const animatedLayout = useAnimatedLayout({containerRef: scrollRef}); so the content beside the edge glides with it and no gap opens. An optional two-state form holds the element's old state as FR8's swap holds it, while the element rides the glide of the content beside its moving edge; a reversal - from the shown state springs from where it is. Under Reduce Motion it follows + from the shown state starts from where it is. Under Reduce Motion it follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise. Every edge reveal in Core is this one: - Collapsible, TreeList and Table's row expansion keep its block-end form @@ -211,20 +216,26 @@ Out of coverage, each taking its final place at once: - `architecture:react-component-runtime` INV12 produces the glide. - `architecture:react-component-runtime` INV12 also gains the in-place swap (FR8); no component swaps yet. +- The glide, the in-place swap and the edge reveal play `springs.settle` at + every character; FR4 moves them to `--ease-move` and + `--duration-medium-min`, with `springs.settle` only under + `character: 'native'`. The SideNav rail and AppShell's content area, which + share that motion (`component:SideNav` FR6, `component:AppShell` FR17), + follow with them. - INV12 also produces the edge reveal (FR9). SideNav's rail clip and ride move into it from SideNav's own code, and a resizable LayoutPanel's collapse and open, which today jump, adopt it; LayoutPanel's docs say a collapse slides. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| ------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1, FR2 | Unit and browser tests with and without a handle | a handle on TreeList; no handle; a ScrollableArea ref | a handle with public members, behavior that changes without a handle, or ScrollableArea registering itself fails | -| FR3, Coverage | Real-browser tests, one per Coverage row | each Coverage row; mixed Astryx and plain content; two changes in one frame; an area scrolled mid-list | an element that behaves other than its row says, an element moved twice for one frame, content past the area that moves, or the area scrolling fails | -| FR4 | Real-browser frame capture under a coarse and a fine pointer, with and without Reduce Motion | a changed element and its visible followers | starting on different frames or reading different motion, a component-specific timing, or Reduce Motion applied to one and not the other fails | -| FR5 | Fine-pointer before-and-after evidence on each adopting pull request, from the component's Storybook story | a mouse on each adopting component | a glide missing under a mouse, or an adopting pull request without that evidence, fails | -| FR8 | Unit tests of the swap and real-browser proofs of its adopters | a fast load, a slow load, a forward and a back page change, Reduce Motion | an empty area during a swap, a placeholder flashed for a fast load, travel under Reduce Motion, a layer exit played by it, or a public export fails | -| FR9 | Unit tests of the reveal's measurement and real-browser frame capture of each adopter | Collapsible opening; the SideNav rail collapsing and expanding; a start and an end LayoutPanel collapsing and opening by toggle, Enter, the grow arrow, a controlled change and a drag past the threshold; a drag reopening a pane; a hook-only pane; focus inside a collapsing pane; Reduce Motion | a gap opening beside a moving edge, two motions for one rail collapse, a reveal during a drag that reopens a pane, a reveal on a hook-only pane, focus left inside a concealed pane, a concealed pane reachable by Tab, or a public export fails | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR2 | Unit and browser tests with and without a handle | a handle on TreeList; no handle; a ScrollableArea ref | a handle with public members, behavior that changes without a handle, or ScrollableArea registering itself fails | +| FR3, Coverage | Real-browser tests, one per Coverage row | each Coverage row; mixed Astryx and plain content; two changes in one frame; an area scrolled mid-list | an element that behaves other than its row says, an element moved twice for one frame, content past the area that moves, or the area scrolling fails | +| FR4 | Real-browser frame capture under a coarse and a fine pointer, with and without Reduce Motion | a changed element and its visible followers at each `character`, `'native'` included, and at `tempo` 0.75 and 1.5 | starting on different frames or reading different motion, a component-specific timing, a glide that ignores the theme's `character` or `tempo`, a spring played under a character other than `'native'`, or Reduce Motion applied to one and not the other fails | +| FR5 | Fine-pointer before-and-after evidence on each adopting pull request, from the component's Storybook story | a mouse on each adopting component | a glide missing under a mouse, or an adopting pull request without that evidence, fails | +| FR8 | Unit tests of the swap and real-browser proofs of its adopters | a fast load, a slow load, a forward and a back page change, Reduce Motion | an empty area during a swap, a placeholder flashed for a fast load, travel under Reduce Motion, a layer exit played by it, or a public export fails | +| FR9 | Unit tests of the reveal's measurement and real-browser frame capture of each adopter | Collapsible opening; the SideNav rail collapsing and expanding; a start and an end LayoutPanel collapsing and opening by toggle, Enter, the grow arrow, a controlled change and a drag past the threshold; a drag reopening a pane; a hook-only pane; focus inside a collapsing pane; Reduce Motion | a gap opening beside a moving edge, two motions for one rail collapse, a reveal during a drag that reopens a pane, a reveal on a hook-only pane, focus left inside a concealed pane, a concealed pane reachable by Tab, or a public export fails | ## Decision log @@ -244,6 +255,18 @@ element, inside the area and only where it is visible. Rejected: wrapping following content in an element, and opting in each following element. +### DEC-2 — The glide follows the theme's character; the measured spring is opt-in + +**Reference:** `spec:AST-105/DEC-2` +**Decider:** `cixzhang`, `2026-11-02` + +A glide is a move like any other, so it takes the theme's move curve and +duration, and a theme that sets `character` or `tempo` sees its glides change +with the rest of its motion, under every pointer. The measured spring stays +available to a theme that sets `character: 'native'` (`spec:AST-108/DEC-2`). + +Rejected: the measured spring as every theme's glide, under every pointer. + ## Open questions None. diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index ad2cd5e6582d0..fe9ba5a2a3392 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -62,9 +62,12 @@ defineTheme({ `{tempo, character, reducedMotion}`: - `tempo` is a number, default `1`, clamped to 0.75–1.5; it scales every motion duration; - - `character` is `'crisp'`, `'standard'` (default), or `'expressive'`; it - picks the entrance and move curves and the spring bounce. A continuous bounce - is not offered; + - `character` is `'crisp'`, `'standard'` (default), `'expressive'`, or + `'native'`; it picks the entrance and move curves and the spring bounce. + `'native'` keeps the `'standard'` curves and bounce, and a motion that + plays a curve at every other character plays its measured spring instead, + such as the animated-layout glide (`spec:AST-105` FR4). A continuous + bounce is not offered; - `reducedMotion` is `'system'` (default) or `'always'`; `'always'` applies `design:reduced-motion` DR1–DR3 for everyone, and `'system'` follows each person's setting. No value loosens Reduce Motion. @@ -77,14 +80,14 @@ defineTheme({ `'standard'`, the tokens keep their released values: `--duration-*` 130, 175, 230, 310, 410, 550, 730, 975 and 1300 ms, and `--ease-standard` `cubic-bezier(0.24, 1, 0.4, 1)`; `--ease-entrance` and `--ease-move` equal - `--ease-standard` at `'standard'`. The curves at `'crisp'` and + `--ease-standard` at `'standard'` and at `'native'`. The curves at `'crisp'` and `'expressive'` are a decelerate-only curve and a curve with a slight overshoot. - **FR3 — The animator's tables derive from the same knobs.** The animator's base tables, every role in `spec:AST-094` FR4, derive from the theme's `tempo` and `character` at their source: a spring's duration scales by `tempo`, and `character` offsets the role's measured bounce by an amount - times the role's fixed weight: `'standard'` adds nothing, and `'crisp'` and + times the role's fixed weight: `'standard'` and `'native'` add nothing, and `'crisp'` and `'expressive'` shift it down and up, so a role whose weight is zero, such as an exit, a fade, or a full-screen move, keeps its measured bounce at every character. With @@ -159,6 +162,11 @@ defineTheme({ script, also honour it, as do adaptation rules keyed on `when: {motion}` (`spec:AST-012`). - `design:reduced-motion` gains DR2a: a theme may reduce motion for everyone. +- `character` gains `'native'`. The animated-layout glide, swap and edge + reveal play `--ease-move` over `--duration-medium-min` at every other + character (`spec:AST-105` FR4), and the motion, easing-token and theme + docs, the theme template, the theme-authoring guide and + `architecture:theme-authoring-contract` describe the fourth value (FR7). - Spinner and Skeleton read their loop durations before `tempo` scales them (FR4), so their timing is unchanged under every existing theme; `component:Spinner` FR3 and `component:Skeleton` FR4 say so. @@ -176,7 +184,7 @@ defineTheme({ | Contract | Verification | Representative states | Mutation or failure expectation | | -------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR2 | Theme unit tests over generated CSS | each `tempo` bound and 1; each `character`; `reducedMotion` both values; no `motion` | a token off its formula, the released tokens changed at the defaults, an exit curve that changes, or a value outside a clamp fails | +| FR1, FR2 | Theme unit tests over generated CSS | each `tempo` bound and 1; each `character`; `reducedMotion` both values; no `motion` | a token off its formula, the released tokens changed at the defaults, an exit curve that changes, a `'native'` token that differs from its `'standard'` value, or a value outside a clamp fails | | FR3 | Tests that the CSS tokens and the animator's tables derive from the same knob values | each role at the defaults and at each knob value; an adaptation rule that sets `tempo` or `character` | a role off its measured value at the defaults, a role that ignores a knob it follows, or a weight-0 role whose bounce changes with `character` fails | | FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, an element's entrance and exit, a sheet and its scrim, a menu's grow and content, a CSS fade with a spring | paired animations that end more than one frame apart fail | | FR4 | Unit tests of each fixed item | finger tracking, momentum, rubber band, keyboard, press and hover clocks, loops, Reduce Motion cap; Spinner and Skeleton loops at each tempo | a fixed item that moves with `tempo` or `character`, or a cross-fade longer than 200 ms, fails | @@ -199,6 +207,19 @@ internal" for exactly these three knobs. Rejected: a public token per motion group. +### DEC-2 — The measured springs are a character + +**Reference:** `spec:AST-108/DEC-2` +**Decider:** `cixzhang`, `2026-11-02` + +Where a motion offers both a curve and a measured spring, a theme opts into +the spring by setting `character: 'native'`; every other character keeps that +motion on the theme's curves. `'native'` changes no CSS token and no spring +bounce, so it differs from `'standard'` only where a motion offers both. This +amends DEC-1's three character steps to four. + +Rejected: a separate knob for spring motion. + ## Open questions - **OQ1 — Whether a theme's motion values get a public resolver.** diff --git a/docs/specs/components/AppShell/AppShell.spec.md b/docs/specs/components/AppShell/AppShell.spec.md index e4de333ec28de..1765098fcbf19 100644 --- a/docs/specs/components/AppShell/AppShell.spec.md +++ b/docs/specs/components/AppShell/AppShell.spec.md @@ -185,7 +185,7 @@ the only component record in the AppShell root. | FR14 | Main content stays mounted across the breakpoint (`family:layout-regions` FR9); in addition, input values and focus inside `children` survive in both directions. | Current source and tests | | FR15 | `className`, `style`, and `xstyle` merge onto the root after component styles. | Current source | | FR16 | With `elevated`, a present `topNav`, and an inline side nav, the content area is a raised surface whose block-start, inline-start corner uses the page radius; otherwise the content wrapper creates no layout box. | Current source and stories | -| FR17 | While the side nav collapses or expands, the content area swaps its old wrap for its new one in place: the old wrap slides and fades out while the new wrap slides and fades in, on the side nav's one clock, `spec:AST-105` FR4's one shared motion (`springs.settle`; `component:SideNav` FR6). The old wrap is one transient copy, inert and hidden from assistive technology, removed when the swap ends (`architecture:component-style-authoring` INV7). When the old wrap is not copied, such as on a page too large to copy, no visible gap opens between the rail and the content during an expand. The content area never animates its width. Under reduced motion the swap follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise. | `spec:AST-105` FR8; `component:SideNav` FR6 | +| FR17 | While the side nav collapses or expands, the content area swaps its old wrap for its new one in place: the old wrap slides and fades out while the new wrap slides and fades in, on the side nav's one clock, `spec:AST-105` FR4's one shared motion (`component:SideNav` FR6). The old wrap is one transient copy, inert and hidden from assistive technology, removed when the swap ends (`architecture:component-style-authoring` INV7). When the old wrap is not copied, such as on a page too large to copy, no visible gap opens between the rail and the content during an expand. The content area never animates its width. Under reduced motion the swap follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise. | `spec:AST-105` FR8; `component:SideNav` FR6 | | FR18 | AppShell is a layout root: at its root it applies Layout's `layoutReset` (`component:Layout` FR8), resetting every layout value its ancestors pass down, so its header, side, and main regions lay out the same inside a padded card, inside a Layout, or at the page root. The published container padding (`--container-padding-inline-start`, `--container-padding-inline-end`, `--container-padding-block-start`, `--container-padding-block-end`) resets to zero, so AppShell's Layout escapes nothing (`component:Layout` FR2). The Layout inset and region values (`--layout-padding-outer-x`, `--layout-padding-outer-y`, `--layout-padding-own-outer-x`, `--layout-padding-own-outer-y`, `--layout-padding-inner-x`, `--layout-padding-inner-y`, `--layout-content-width`, `--layout-alignment-width`, `--_layout-flush-block-start`, `--_layout-flush-block-end`), Section padding propagation (`--_section-padding-propagated`), and the container height cap (`--container-max-height`) reset to unset, as at the page root. The Layout area, slot, and divider-default contexts (`LayoutAreaContext`, `LayoutSlotsContext`, `LayoutDividerContext`) reset to their defaults. These names are internal protocol, not theme API (`architecture:container-padding` INV1). A caller's own props and styles on AppShell (`height`, `variant`, `contentPadding`, `mobileNav`, `className`, `style`, `xstyle`) still apply after the reset (FR15). Theme tokens, color mode, writing direction, and translations keep inheriting. | DEC-1; `architecture:container-padding` INV6 (the same reset at overlay roots) | ### Allowed variation diff --git a/docs/specs/components/SideNav/SideNav.spec.md b/docs/specs/components/SideNav/SideNav.spec.md index 8a1fc41b16918..41e822d5b746d 100644 --- a/docs/specs/components/SideNav/SideNav.spec.md +++ b/docs/specs/components/SideNav/SideNav.spec.md @@ -113,14 +113,14 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | Draft review state | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1 | SideNav renders its navigation children in a scrollable list, which may be grouped by SideNavSection. A SideNavItem with children renders them, after its own row, in a group labelled by the parent's label; unless `collapsible` is `false`, the group expands and collapses and is hidden from assistive technology and inert while collapsed. | Current source, docs, and tests | Current behavior | -| FR2 | In a collapsed rail, a SideNavItem without an icon is not rendered; a leaf item renders icon-only, named by a non-blank caller `aria-label` or else its label; an item with children renders an icon-only trigger that opens a flyout listing its children as the flyout's top level. | Current source, docs, and tests | Current behavior | -| FR3 | In an expanded rail, in a flyout, and in the mobile navigation drawer, every SideNavItem row MUST span the full inline size of its list at any depth. A row at depth `n` MUST start its content `n` × `--spacing-6` beyond the row's own inline-start padding for its size, inside the row; the nested group MUST add no inset. A flyout's top level is depth 0. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged | -| FR4 | The indent area MUST be part of the row's target: a click there, including a modified or middle click on a link row, does what the same click on the row's primary element does. When the row also carries an independent expand toggle or `actions`, the indent belongs to the primary link or button, and the toggle and actions keep their own activation at the row's trailing edge. | `component:SideNav/DEC-1`; `family:navigation-destinations` | Settled intent; implementation may be staged | -| FR5 | An expanded nested group MUST NOT clip its rows once it has finished opening, so a nested row's focus ring paints in full. The group MAY clip while collapsed and while it opens or closes. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged | -| FR6 | Collapsing or expanding the rail plays one motion on one clock, `spec:AST-105` FR4's one shared motion (`springs.settle`): layout snaps to the final rail width at once, with no width animation, and the rail reveals or covers its labels by a clip, its edge riding the main content's glide and its labels and collapsed icons fading on the same clock (`spec:AST-105` FR9's inline two-state form; the clip and the ride are the shared edge reveal's, and SideNav's own code keeps only the fades; the rail lends no pane to its resize controller, so a collapse plays once; implementation gap: the clip and the ride still run in SideNav's own rail motion code, not in the shared edge reveal): a collapse ends in the real collapsed state, icons only, and an expand ends in the real expanded state, with its labels. On a collapse or an expand the rail's old state is one transient copy, inert and hidden from assistive technology, removed when the motion ends. The main content moves on the same clock, as `component:AppShell` FR17 states. Under reduced motion the rail follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise. Collapsing or expanding keeps the nav and its items mounted, a `resizable` rail included, so item state such as an open nested group, and focus, persist (AR3 governs focus on what the collapsed rail does not render). | `component:SideNav/DEC-2`; `component:AppShell` FR17; `spec:AST-105` FR8, FR9 | Verified by `SideNavRailMotion.test.tsx` and `SideNavRailMotion.interaction.browser.spec.ts`; the `reducedMotion: 'always'` theme case pending | +| ID | Invariant | Basis | Draft review state | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | SideNav renders its navigation children in a scrollable list, which may be grouped by SideNavSection. A SideNavItem with children renders them, after its own row, in a group labelled by the parent's label; unless `collapsible` is `false`, the group expands and collapses and is hidden from assistive technology and inert while collapsed. | Current source, docs, and tests | Current behavior | +| FR2 | In a collapsed rail, a SideNavItem without an icon is not rendered; a leaf item renders icon-only, named by a non-blank caller `aria-label` or else its label; an item with children renders an icon-only trigger that opens a flyout listing its children as the flyout's top level. | Current source, docs, and tests | Current behavior | +| FR3 | In an expanded rail, in a flyout, and in the mobile navigation drawer, every SideNavItem row MUST span the full inline size of its list at any depth. A row at depth `n` MUST start its content `n` × `--spacing-6` beyond the row's own inline-start padding for its size, inside the row; the nested group MUST add no inset. A flyout's top level is depth 0. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged | +| FR4 | The indent area MUST be part of the row's target: a click there, including a modified or middle click on a link row, does what the same click on the row's primary element does. When the row also carries an independent expand toggle or `actions`, the indent belongs to the primary link or button, and the toggle and actions keep their own activation at the row's trailing edge. | `component:SideNav/DEC-1`; `family:navigation-destinations` | Settled intent; implementation may be staged | +| FR5 | An expanded nested group MUST NOT clip its rows once it has finished opening, so a nested row's focus ring paints in full. The group MAY clip while collapsed and while it opens or closes. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged | +| FR6 | Collapsing or expanding the rail plays one motion on one clock, `spec:AST-105` FR4's one shared motion: layout snaps to the final rail width at once, with no width animation, and the rail reveals or covers its labels by a clip, its edge riding the main content's glide and its labels and collapsed icons fading on the same clock (`spec:AST-105` FR9's inline two-state form; the clip and the ride are the shared edge reveal's, and SideNav's own code keeps only the fades; the rail lends no pane to its resize controller, so a collapse plays once; implementation gap: the clip and the ride still run in SideNav's own rail motion code, not in the shared edge reveal): a collapse ends in the real collapsed state, icons only, and an expand ends in the real expanded state, with its labels. On a collapse or an expand the rail's old state is one transient copy, inert and hidden from assistive technology, removed when the motion ends. The main content moves on the same clock, as `component:AppShell` FR17 states. Under reduced motion the rail follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise. Collapsing or expanding keeps the nav and its items mounted, a `resizable` rail included, so item state such as an open nested group, and focus, persist (AR3 governs focus on what the collapsed rail does not render). | `component:SideNav/DEC-2`; `component:AppShell` FR17; `spec:AST-105` FR8, FR9 | Verified by `SideNavRailMotion.test.tsx` and `SideNavRailMotion.interaction.browser.spec.ts`; the `reducedMotion: 'always'` theme case pending | ### Allowed variation