From 76282bc745cd90ed62486304533e723f779e14ff Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:25:10 -0700 Subject: [PATCH 01/14] =?UTF-8?q?spec(AST-108):=20theme=20motion=20is=20th?= =?UTF-8?q?ree=20knobs=20=E2=80=94=20tempo,=20character,=20reducedMotion?= =?UTF-8?q?=20(AST-094=20DEC-15,=20FR4;=20reduced-motion=20DR2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/motion.md | 4 +- docs/design/reduced-motion.md | 4 + docs/specs/AST-090-exit-motion/spec.md | 10 +- .../specs/AST-094-touch-presentations/spec.md | 30 +++- docs/specs/AST-108-theme-motion/spec.md | 160 ++++++++++++++++++ 5 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 docs/specs/AST-108-theme-motion/spec.md diff --git a/docs/design/motion.md b/docs/design/motion.md index 4b0971cd6927f..6018bc9cbc2bc 100644 --- a/docs/design/motion.md +++ b/docs/design/motion.md @@ -32,7 +32,9 @@ when motion is reduced. - **DR2 — Weight determines timing.** Small local feedback SHOULD feel faster than large entrances, exits, or continuous movement. - **DR3 — Movement settles naturally.** Easing SHOULD communicate controlled - deceleration rather than ornamental bounce or elasticity. + deceleration rather than ornamental bounce or elasticity; a theme's + expressive character may overshoot on entrances and moves only + (`spec:AST-108`). - **DR4 — Stable content stays stable.** Motion SHOULD avoid unnecessary layout disruption and preserve reading and interaction context. - **DR5 — Reduced motion preserves meaning.** Every animated transition MUST have diff --git a/docs/design/reduced-motion.md b/docs/design/reduced-motion.md index 9787d2914d5e9..2e16af9ab7cd9 100644 --- a/docs/design/reduced-motion.md +++ b/docs/design/reduced-motion.md @@ -72,6 +72,10 @@ trackpad, or keyboard the change is immediate. still cross-fades. Native scrolling is the browser's and is untouched; a coast Astryx animates itself after a throw behaves like native momentum under Reduce Motion. +- **DR2a — A theme may reduce motion for everyone.** A theme's + `reducedMotion: 'always'` applies DR1–DR3 for every person, whatever their + setting; `'system'` follows each person's setting (`spec:AST-108`). No theme + option loosens DR1–DR4. - **DR3 — Fine-pointer presentations change at once.** Under reduced motion, every other DR1 transition, when the primary pointer is not coarse, MUST complete immediately, with no animated property. diff --git a/docs/specs/AST-090-exit-motion/spec.md b/docs/specs/AST-090-exit-motion/spec.md index dcf138257c292..057ddc619536c 100644 --- a/docs/specs/AST-090-exit-motion/spec.md +++ b/docs/specs/AST-090-exit-motion/spec.md @@ -100,11 +100,11 @@ components read through two shared primitives is a system fact. FR1–FR13 govern dialog and layer surfaces in a pointer presentation. FR14 governs them in a touch presentation. -- **FR1 — Exits are off by default.** `--duration-exit-dialog` and - `--duration-exit-layer` default to `0ms`. A surface whose kind's exit - duration is zero closes at once: it is hidden in the update that closes it, - and focus, callbacks, and dismissal behave as they do for a surface with no - exit. +\1 When a theme sets them, its tempo scales them (`spec:AST-108`). A surface whose kind's exit +duration is zero closes at once: it is hidden in the update that closes it, +and focus, callbacks, and dismissal behave as they do for a surface with no +exit. + - **FR2 — A theme turns on each kind.** A surface plays its exit when its kind's exit duration, resolved on the surface, is greater than zero. The two kinds are independent. The durations resolve like any token, so a theme or a diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 94fda268d7a00..004d3cad6ee58 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -106,18 +106,23 @@ a measured, sourced difference exists. touch presentation plays MUST be a motion role named for its use (`architecture:theme-tokens` INV3). The roles this record names are listed below with their defaults and sources; further roles a component's own record measures join through the - generated motion role table (DEC-5). The role's value is its - measured value (FR1, FR2). The roles are internal until the public - motion-token set is decided, so a theme cannot override them yet (DEC-11). A component MUST read touch motion through these roles - and MUST NOT play a literal. Once a role is public, a theme value MUST reach the motion at its + generated motion role table (DEC-5). With tempo 1 and character + standard, every role plays its measured value exactly (FR1, FR2); a theme's + tempo scales durations and its character sets curves and bounce through + fixed per-role weights (`spec:AST-108`), and roles on the fixed list ignore + both: the on-screen keyboard (`spec:AST-099` FR8); gesture thresholds, the + content-touch delay, and momentum (`spec:AST-002` FR3); finger tracking, the + rubber band, press clocks, and busy loops; and the 200 ms Reduce Motion cap. + The per-role tables stay internal (DEC-15). A component MUST read touch motion through these roles + and MUST NOT play a literal. A theme's knob values MUST reach the motion at its start, without changing how the motion runs (FR5, FR6). Motion that must stay in step with the on-screen keyboard is not a theme role: it rides the keyboard's own motion (`spec:AST-099` FR8, `architecture:react-component-runtime` INV11). Gesture thresholds, the timings that decide who gets a touch (the content-touch delay, the scroll brake, momentum memory) and haptics are not - motion: they stay internal measured values and are not public concepts (`spec:AST-002` FR3). When the public set is decided, its roles join the motion configuration of - `architecture:theme-authoring-contract` and `architecture:theme-tokens`, - whose rules are unchanged until then. It departs from the draft `design:motion` DR3 + motion: they stay internal measured values and are not public concepts (`spec:AST-002` FR3). The three knobs are the motion configuration of + `architecture:theme-authoring-contract` and `architecture:theme-tokens` + (`spec:AST-108`). It departs from the draft `design:motion` DR3 wherever the native motion settles with an overshoot or stretches with a rubber band. @@ -702,6 +707,17 @@ iOS 26.2 simulator measures it. Rejected: the 150 ms delay on every menu row. +### DEC-15 — The public motion set is three theme knobs; per-role tables stay internal + +**Reference:** `spec:AST-094/DEC-15` +**Decider:** `cixzhang`, `2026-10-26` + +A theme reaches touch motion only through `spec:AST-108`'s three knobs, tempo, +character, and reduced motion, which transform the measured roles; the +per-role tables and weights stay internal. This supersedes DEC-11's "all +internal" for exactly these three knobs; DEC-11's rejected alternative, public +per-component roles, stays rejected. + ## Open questions - **OQ1 — Scroll view bounce-back curve.** What curve brings a scroll view diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md new file mode 100644 index 0000000000000..606a21243b006 --- /dev/null +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -0,0 +1,160 @@ +--- +schema_version: 4 +template_version: 2 +kind: system-spec +id: spec:AST-108 +authority: current +archive_reason: null +superseded_by: null +approved_by: cixzhang +approved_at: 2026-10-26 +phase: accepted +owners: [cixzhang] +affects_architecture: + [architecture:theme-tokens, architecture:theme-authoring-contract] +affects_families: [] +affects_contributing: [] +affects_consumer_docs: [motion, tokens, theme] +--- + +# Theme motion system spec + + + +```json +{ + "paths": ["packages/core/src/theme/**", "packages/core/src/motion/**"] +} +``` + + + +## Intent + +A theme sets how fast and how lively its motion feels, and whether motion is +reduced for everyone, with three knobs. The same knobs drive the CSS motion +tokens and the animator's springs and curves, and animations that run together +start and land together. Every per-use motion value stays internal. + +## Non-goals + +- Per-role or per-component motion settings for themes. +- Loosening Reduce Motion: no option turns off `design:reduced-motion` + DR1–DR4 for a person who asked for it. +- A public resolver for a theme's motion values (OQ1). + +## Public contract + +```ts +defineTheme({ + name: 'Brand', + motion: {tempo: 1, character: 'standard', reducedMotion: 'system'}, +}); +``` + +## Requirements + +- **FR1 — Three motion knobs.** `defineTheme`'s `motion` takes + `{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. Three steps are + the recommendation the owner adopted with the knobs; 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. +- **FR2 — The knobs generate the CSS motion tokens.** The theme's + `--duration-*` tokens are their base values times `tempo`. `character` sets + the job-named `--ease-entrance` and `--ease-move` tokens, declared in + `tokens.stylex.ts`, and `--ease-standard`; `--ease-exit` is the same at + every character, so exits stay quiet. With `tempo` 1 and `character` + `'standard'`, the tokens are today's: `--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 + `'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 its bounce is the character's bounce amount times the role's + fixed weight, so exits, fades, and full-screen moves never overshoot. With + `tempo` 1 and `character` `'standard'`, every role plays its measured value + exactly. A spring keeps its measured physics, scaled by `tempo` and shaped + by `character`; it does not follow a CSS duration or easing twin. The roles + and their weights stay internal. +- **FR4a — Coordinated animations share one clock.** Animations that run + together start and land together: they play the same motion role, or the CSS + part is timed from the spring's computed settle time, and never as two + independently timed curves. This covers an element and the siblings that + glide around it (`spec:AST-105`'s one shared timing), `presence`'s enter and + exit, a sheet and its scrim, a menu's grow and its content, and a CSS fade + paired with a JavaScript spring, such as MobileNav's close timed from its + computed duration. +- **FR4 — Some motion is fixed on purpose.** The knobs do not move + finger-following and drag motion, scroll momentum and decay, the rubber + band, the on-screen keyboard's motion (`spec:AST-099` FR8), press and hover + clocks, gesture thresholds (`spec:AST-002` FR3), busy loops such as spinners + and skeletons, or the 200 ms Reduce Motion cap; `tempo` may shorten a + Reduce Motion cross-fade but never lengthens it. +- **FR5 — Existing motion configuration keeps working.** `defineTheme`'s + `motion` also accepts the existing `MotionScaleConfig`, and + `expandMotionScale` and the `MotionScale*` types are unchanged. A + `MotionScaleConfig` keeps generating its tokens and gives the animator the + `tempo` its `medium` value implies against the default. Explicit + `MotionScaleConfig` fields and explicit `tokens` win over `tempo` and + `character`, and an extending theme's motion configuration replaces its + base's, as today. +- **FR6 — Server rendering gets the same values.** A theme is plain data, and + the theme utilities run on the server. `'system'` is resolved by emitting + the reduced token set behind `prefers-reduced-motion` in the generated CSS, + so the server never guesses and nothing flashes at hydration. An internal, + pure `resolveThemeMotion(theme)` returns the knobs, durations, easings, and + role tables for the animator and for server code; it touches no DOM and has + no import-time side effects. The animator reads a person's Reduce Motion + setting only when an animation starts, on the client. +- **FR7 — Docs ship with it.** The motion, duration-token, and easing-token + docs, the theme template, the theme-authoring guide, and + `architecture:theme-authoring-contract` describe the three knobs, the fixed + list, and the precedence before the implementation lands. + +## Current-state impact + +- `defineTheme`'s `motion` widens to accept the three knobs beside the existing + `MotionScaleConfig`; existing themes render exactly as before. +- Core gains `--ease-entrance`, `--ease-move`, and `--ease-exit`. +- `spec:AST-094` FR4 and `design:reduced-motion` DR2 and the draft + `design:motion` DR3 and `spec:AST-090` are amended to match. + +## Verification + +| 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, today's tokens changed at the defaults, an exit curve that changes, 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 | a role off its measured value at the defaults, a role that ignores a knob it follows, or overshoot on a weight-0 role fails | +| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 | a fixed item that moves with a knob, or a cross-fade longer than 200 ms, fails | +| FR5 | Theme tests with today's configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme | changed output for an existing theme, or a knob that beats an explicit field, fails | +| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules | a DOM access at import or on the server, a missing media-query set, or a hydration change fails | +| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | + +## Decision log + +### DEC-1 — A theme's motion is three knobs; per-role tables stay internal + +**Reference:** `spec:AST-108/DEC-1` +**Decider:** `cixzhang`, `2026-10-26` + +Theme authors set how fast and how lively motion feels and whether it is +reduced for everyone; few want per-use values. Three knobs drive CSS tokens and JavaScript springs from the same values, and the measured +per-role values stay internal. This supersedes `spec:AST-094` DEC-11's "all +internal" for exactly these three knobs. + +Rejected: a public token per motion group. + +## Open questions + +- **OQ1 — Whether a theme's motion values get a public resolver.** + (`human-design`) Open. `resolveThemeMotion` is internal. From 843655ea2acf7ae6de47c7395135844e98313b19 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:27:32 -0700 Subject: [PATCH 02/14] spec(AST-108): character offsets each role's measured bounce; BottomSheet's scrim follows the sheet's settle time --- docs/specs/AST-108-theme-motion/spec.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 606a21243b006..89713b4b0c118 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -79,8 +79,10 @@ defineTheme({ - **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 its bounce is the character's bounce amount times the role's - fixed weight, so exits, fades, and full-screen moves never overshoot. With + `tempo`, and `character` offsets the role's measured bounce by an amount + times the role's fixed weight: `'standard'` adds nothing, and `'crisp'` and + `'expressive'` shift it down and up, so exits, fades, and full-screen moves, + whose weight is zero, never overshoot. With `tempo` 1 and `character` `'standard'`, every role plays its measured value exactly. A spring keeps its measured physics, scaled by `tempo` and shaped by `character`; it does not follow a CSS duration or easing twin. The roles @@ -92,7 +94,8 @@ defineTheme({ glide around it (`spec:AST-105`'s one shared timing), `presence`'s enter and exit, a sheet and its scrim, a menu's grow and its content, and a CSS fade paired with a JavaScript spring, such as MobileNav's close timed from its - computed duration. + computed duration and BottomSheet's scrim, whose fade lasts the sheet's + computed settle time. - **FR4 — Some motion is fixed on purpose.** The knobs do not move finger-following and drag motion, scroll momentum and decay, the rubber band, the on-screen keyboard's motion (`spec:AST-099` FR8), press and hover @@ -125,6 +128,8 @@ defineTheme({ - `defineTheme`'s `motion` widens to accept the three knobs beside the existing `MotionScaleConfig`; existing themes render exactly as before. - Core gains `--ease-entrance`, `--ease-move`, and `--ease-exit`. +- BottomSheet's scrim fades over `--duration-medium` while the sheet settles + on its spring; it moves to the spring's settle time (FR4a). - `spec:AST-094` FR4 and `design:reduced-motion` DR2 and the draft `design:motion` DR3 and `spec:AST-090` are amended to match. From ef37748cbde0d5056717fc7b90f120043f8a91d5 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 17:20:36 -0700 Subject: [PATCH 03/14] spec(AST-090): restore FR1's opening, lost to an editing slip --- docs/specs/AST-090-exit-motion/spec.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/specs/AST-090-exit-motion/spec.md b/docs/specs/AST-090-exit-motion/spec.md index 057ddc619536c..0a274d993d694 100644 --- a/docs/specs/AST-090-exit-motion/spec.md +++ b/docs/specs/AST-090-exit-motion/spec.md @@ -100,11 +100,12 @@ components read through two shared primitives is a system fact. FR1–FR13 govern dialog and layer surfaces in a pointer presentation. FR14 governs them in a touch presentation. -\1 When a theme sets them, its tempo scales them (`spec:AST-108`). A surface whose kind's exit -duration is zero closes at once: it is hidden in the update that closes it, -and focus, callbacks, and dismissal behave as they do for a surface with no -exit. - +- **FR1 — Exits are off by default.** `--duration-exit-dialog` and + `--duration-exit-layer` default to `0ms`. When a theme sets them, its tempo + scales them (`spec:AST-108`). A surface whose kind's exit + duration is zero closes at once: it is hidden in the update that closes it, + and focus, callbacks, and dismissal behave as they do for a surface with no + exit. - **FR2 — A theme turns on each kind.** A surface plays its exit when its kind's exit duration, resolved on the surface, is greater than zero. The two kinds are independent. The durations resolve like any token, so a theme or a From 8cbc8e738a264d760a8ec84ba92928417ad96ce9 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 21:39:11 -0700 Subject: [PATCH 04/14] spec(AST-108, AST-094, AST-090): exits keep --ease-standard; implied tempo unclamped; per-role override promises retired; explicit exit tokens win --- docs/specs/AST-090-exit-motion/spec.md | 4 ++-- docs/specs/AST-094-touch-presentations/spec.md | 18 +++++++++--------- docs/specs/AST-108-theme-motion/spec.md | 16 ++++++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/specs/AST-090-exit-motion/spec.md b/docs/specs/AST-090-exit-motion/spec.md index 0a274d993d694..12aa7a8e1a232 100644 --- a/docs/specs/AST-090-exit-motion/spec.md +++ b/docs/specs/AST-090-exit-motion/spec.md @@ -101,8 +101,8 @@ FR1–FR13 govern dialog and layer surfaces in a pointer presentation. FR14 governs them in a touch presentation. - **FR1 — Exits are off by default.** `--duration-exit-dialog` and - `--duration-exit-layer` default to `0ms`. When a theme sets them, its tempo - scales them (`spec:AST-108`). A surface whose kind's exit + `--duration-exit-layer` default to `0ms`. A theme's tempo does not scale an exit + duration the theme sets explicitly (`spec:AST-108` FR5). A surface whose kind's exit duration is zero closes at once: it is hidden in the update that closes it, and focus, callbacks, and dismissal behave as they do for a surface with no exit. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 004d3cad6ee58..c095bbc5cc509 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -64,8 +64,8 @@ a measured, sourced difference exists. deceleration rate, rubber-band coefficient, gesture threshold and haptic moment a touch presentation uses MUST default to the measured native value for that use (FR2), and every component that plays the same motion or - gesture MUST use the same value; once the roles are public, a theme MAY - override a motion role (FR4). + gesture MUST use the same value; a theme reaches the roles only + through its motion knobs (FR4, DEC-15). No component uses a value of its own where a measured value exists. The same values apply on every touch platform; a platform gets a value of its own only where a measured, sourced difference exists (FR2). @@ -490,8 +490,8 @@ FR33 apply to menus only. A mouse keeps today's behavior. ## Current-state impact - `architecture:theme-tokens` and `architecture:theme-authoring-contract`: - unchanged until the public motion-token set is decided; then its roles join - the motion configuration (FR4, DEC-11). + the three motion knobs join the motion configuration (FR4, DEC-15, + `spec:AST-108`). - `architecture:interaction-modality`: the touch-presentation conditions (FR13) sit beside the shared modality state; its invariants are unchanged. - `architecture:react-component-runtime`: FR24 states the caller-visible @@ -537,7 +537,7 @@ FR33 apply to menus only. A mouse keeps today's behavior. | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | FR1, FR2 | Value check over every measured value and its uses | every value; a guess and its measurement task; a platform-specific value; two components playing the same motion | an unsourced value, an unknown source kind, a guess without a listed measurement, an unsourced platform split, or two values for one use fails | | FR3 | Unit tests of the spring, decay, rubber band and projection math against an independent integration and values read from UIKit | under-, critically and overdamped springs; release velocities; both rates; overscroll depths | changing a formula or a constant breaks the agreement with UIKit's values | -| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table, or a theme that changes a role before the public set is decided fails | +| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table, or a theme that changes a role other than through its motion knobs fails | | FR5 | Browser proof: an animation keeps advancing frame by frame through a 1 s main-thread block, with no layout or paint per frame | Chromium phone emulation; WebKit | a script-driven or layout-animating implementation freezes or lays out | | FR6 | Unit and browser tests catching an animation mid-flight and retargeting it | catch at peak velocity; retarget with a new spring | a layout read, a jump, or a lost velocity fails | | FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), or an opacity fade over 200 ms fails | @@ -572,8 +572,8 @@ remembered: a number enters the table only with a citable source, and Android touch devices get the same values until a measured, sourced difference is recorded. -The table supplies the defaults and a theme may retune any motion role (FR4): -a product can soften a spring without forking a component, and the measured +The table supplies the defaults and a theme tunes them through its motion +knobs (FR4, DEC-15): a product can soften motion without forking a component, and the measured values stay the ones it starts from. Platform-matched look and feel is not part of this decision. @@ -595,8 +595,8 @@ release runs as a compositor animation of the measured curve (FR5, FR9). **Decider:** `cixzhang`, `2026-10-24` A product tunes motion as it tunes color and type, so every touch spring, -duration and curve is a theme motion role whose default is the measured value -(FR4). Thresholds decide who gets a touch, not how anything looks, so they stay +duration and curve is a motion role whose default is the measured value and +which a theme reaches through its motion knobs (FR4, DEC-15). Thresholds decide who gets a touch, not how anything looks, so they stay internal (`spec:AST-002` FR3). ### DEC-4 — A finger opens a menu on press diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 89713b4b0c118..be9a98da5763f 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -67,9 +67,11 @@ defineTheme({ person's setting. No value loosens Reduce Motion. - **FR2 — The knobs generate the CSS motion tokens.** The theme's `--duration-*` tokens are their base values times `tempo`. `character` sets - the job-named `--ease-entrance` and `--ease-move` tokens, declared in - `tokens.stylex.ts`, and `--ease-standard`; `--ease-exit` is the same at - every character, so exits stay quiet. With `tempo` 1 and `character` + only the job-named `--ease-entrance` and `--ease-move` tokens, declared in + `tokens.stylex.ts`. `--ease-standard`, which today's exits read, and + `--ease-exit`, which defaults to `cubic-bezier(0.42, 0, 1, 1)` (the exit + role's measured curve), are the same at every character, so exits stay + quiet. With `tempo` 1 and `character` `'standard'`, the tokens are today's: `--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 @@ -105,8 +107,9 @@ defineTheme({ - **FR5 — Existing motion configuration keeps working.** `defineTheme`'s `motion` also accepts the existing `MotionScaleConfig`, and `expandMotionScale` and the `MotionScale*` types are unchanged. A - `MotionScaleConfig` keeps generating its tokens and gives the animator the - `tempo` its `medium` value implies against the default. Explicit + `MotionScaleConfig` keeps generating its tokens, so an existing theme's CSS + is unchanged, and gives the animator the `tempo` its `medium` value implies + against the default, used as is rather than clamped to the knob's range. Explicit `MotionScaleConfig` fields and explicit `tokens` win over `tempo` and `character`, and an extending theme's motion configuration replaces its base's, as today. @@ -126,7 +129,8 @@ defineTheme({ ## Current-state impact - `defineTheme`'s `motion` widens to accept the three knobs beside the existing - `MotionScaleConfig`; existing themes render exactly as before. + `MotionScaleConfig`; an existing theme's CSS is unchanged, and its springs + follow the tempo its configuration implies. - Core gains `--ease-entrance`, `--ease-move`, and `--ease-exit`. - BottomSheet's scrim fades over `--duration-medium` while the sheet settles on its spring; it moves to the spring's settle time (FR4a). From e88241696f6bd805e0b2eca87c4f141d75e1ac23 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 21:41:35 -0700 Subject: [PATCH 05/14] spec(AST-108): a MotionScaleConfig's implied tempo drives CSS durations only; springs stay at tempo 1 --- docs/specs/AST-108-theme-motion/spec.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index be9a98da5763f..9a64dbc71a542 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -107,9 +107,10 @@ defineTheme({ - **FR5 — Existing motion configuration keeps working.** `defineTheme`'s `motion` also accepts the existing `MotionScaleConfig`, and `expandMotionScale` and the `MotionScale*` types are unchanged. A - `MotionScaleConfig` keeps generating its tokens, so an existing theme's CSS - is unchanged, and gives the animator the `tempo` its `medium` value implies - against the default, used as is rather than clamped to the knob's range. Explicit + `MotionScaleConfig` keeps generating its CSS duration tokens, and the tempo + it implies drives those durations only: the animator's tables stay at tempo 1 + unless the theme sets `tempo` itself, so an existing theme renders exactly as + before, springs included. Explicit `MotionScaleConfig` fields and explicit `tokens` win over `tempo` and `character`, and an extending theme's motion configuration replaces its base's, as today. @@ -129,8 +130,7 @@ defineTheme({ ## Current-state impact - `defineTheme`'s `motion` widens to accept the three knobs beside the existing - `MotionScaleConfig`; an existing theme's CSS is unchanged, and its springs - follow the tempo its configuration implies. + `MotionScaleConfig`; existing themes render exactly as before. - Core gains `--ease-entrance`, `--ease-move`, and `--ease-exit`. - BottomSheet's scrim fades over `--duration-medium` while the sheet settles on its spring; it moves to the spring's settle time (FR4a). From 55889851315b0df8b55e28c98e11e4b5d86f38d2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 00:32:26 -0700 Subject: [PATCH 06/14] spec(AST-108): extending themes merge field by field; 'system' adds no CSS and 'always' marks the root; adaptation rows; R1 wording --- .../specs/AST-094-touch-presentations/spec.md | 6 ++-- docs/specs/AST-108-theme-motion/spec.md | 33 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 8445ae53ad5e0..27a2f9cc14335 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -108,8 +108,8 @@ a measured, sourced difference exists. sources; further roles a component's own record measures join through the generated motion role table (DEC-5). With tempo 1 and character standard, every role plays its measured value exactly (FR1, FR2); a theme's - tempo scales durations and its character sets curves and bounce through - fixed per-role weights (`spec:AST-108`), and roles on the fixed list ignore + tempo scales durations and its character offsets bounce through fixed + per-role weights (`spec:AST-108` FR3), and roles on the fixed list ignore both: the on-screen keyboard (`spec:AST-099` FR8); gesture thresholds, the content-touch delay, and momentum (`spec:AST-002` FR3); finger tracking, the rubber band, press clocks, and busy loops; and the 200 ms Reduce Motion cap. @@ -729,7 +729,7 @@ A theme reaches touch motion only through `spec:AST-108`'s three knobs, tempo, character, and reduced motion, which transform the measured roles; the per-role tables and weights stay internal. This supersedes DEC-11's "all internal" for exactly these three knobs, and DEC-1's and DEC-3's per-role -retuning, which a theme now does through the knobs; DEC-11's rejected alternative, public +retuning, which a theme does through the knobs; DEC-11's rejected alternative, public per-component roles, stays rejected. ### DEC-22 — An edge band claims only on an element within the band diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 4c2d9bf8c8649..f5d2a0d962d35 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -67,10 +67,10 @@ defineTheme({ - **FR2 — The knobs generate the CSS motion tokens.** The theme's `--duration-*` tokens are their base values times `tempo`. `character` sets only the job-named `--ease-entrance` and `--ease-move` tokens, declared in - `tokens.stylex.ts`. `--ease-standard`, which today's exits read, and + `tokens.stylex.ts`. `--ease-standard`, which the existing exits read, and `--ease-exit`, whose default is `cubic-bezier(0.3, 0, 0.6, 0.6)`, are the same at every character, so exits stay quiet. With `tempo` 1 and `character` - `'standard'`, the tokens are today's: `--duration-*` 130, 175, 230, 310, 410, + `'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 @@ -113,17 +113,17 @@ defineTheme({ unless the theme sets `tempo` itself, so an existing theme renders exactly as before, springs included. Explicit `MotionScaleConfig` fields and explicit `tokens` win over `tempo` and - `character`, and an extending theme's motion configuration replaces its - base's, as today. An adaptation rule may set `tempo` and `character` like + `character`, and an extending theme's motion configuration merges with its + base's field by field: a field or knob the child omits keeps its base's + value. An adaptation rule may set `tempo` and `character` like any other `motion` value (`spec:AST-012` FR4); `reducedMotion` is set on the theme itself, since its root mark has no conditional form. - **FR6 — Server rendering gets the same values.** A theme is plain data, and - the theme utilities run on the server. `'system'` is resolved by emitting - the reduced token set behind `prefers-reduced-motion` in the generated CSS, - so the server never guesses and nothing flashes at hydration. `'always'` - emits the reduced token set unconditionally and marks the theme's root, and - every component style that keys reduced motion on `prefers-reduced-motion` - also keys it on that mark; the animator treats the preference as set. An internal, + the theme utilities run on the server. `'system'` adds no theme CSS: + component styles already key reduced motion on `prefers-reduced-motion`, so + the server never guesses and nothing flashes at hydration. `'always'` marks + the theme's root, and every component style that keys reduced motion on + `prefers-reduced-motion` also keys it on that mark; the animator treats the preference as set. An internal, pure `resolveThemeMotion(theme)` returns the knobs, durations, easings, and role tables for the animator and for server code; it touches no DOM and has no import-time side effects. The animator reads a person's Reduce Motion @@ -137,10 +137,13 @@ defineTheme({ - `defineTheme`'s `motion` widens to accept the three knobs beside the existing `MotionScaleConfig`; existing themes render exactly as before. -- Core gains `--ease-entrance`, `--ease-move`, and `--ease-exit`. +- Core gains `--ease-entrance`, `--ease-move`, and `--ease-exit`, and a + theme-root mark for `reducedMotion: 'always'` that every component style + keying reduced motion on `prefers-reduced-motion` also keys on. +- `design:reduced-motion` gains DR2a: a theme may reduce motion for everyone. - Spinner and Skeleton read their loop durations before `tempo` scales them (FR4), so their timing is unchanged under every existing theme. -- BottomSheet's sheet and scrim share `--duration-medium` today; when the +- BottomSheet's sheet and scrim share `--duration-medium`; when the sheet settles on `springs.settle`, its scrim's fade takes the spring's settle time (FR4a). - `spec:AST-094` FR4 and `design:reduced-motion` DR2 and the draft @@ -150,11 +153,11 @@ 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, today's tokens changed at the defaults, an exit curve that changes, 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 | a role off its measured value at the defaults, a role that ignores a knob it follows, or overshoot on a weight-0 role 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, 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 overshoot on a weight-0 role fails | | FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 a knob, or a cross-fade longer than 200 ms, fails | -| FR5 | Theme tests with today's configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme | changed output for an existing theme, or a knob that beats an explicit field, fails | +| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | | FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion` | a DOM access at import or on the server, a missing media-query set, or a hydration change or a component that keeps travelling under `'always'` fails | | FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | From ec298b6a3d7723e74217eca758c63698621d14cf Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 00:33:11 -0700 Subject: [PATCH 07/14] spec(AST-108): legacy motion fields replace as a unit under extends; only the knobs inherit --- docs/specs/AST-108-theme-motion/spec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index f5d2a0d962d35..07efbb7ef1fc9 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -113,9 +113,9 @@ defineTheme({ unless the theme sets `tempo` itself, so an existing theme renders exactly as before, springs included. Explicit `MotionScaleConfig` fields and explicit `tokens` win over `tempo` and - `character`, and an extending theme's motion configuration merges with its - base's field by field: a field or knob the child omits keeps its base's - value. An adaptation rule may set `tempo` and `character` like + `character`, and an extending theme's `MotionScaleConfig` fields replace its base's as a + unit, the documented extends rule (`author-a-theme`). Only the three knobs + inherit: a knob the child omits keeps its base's value. An adaptation rule may set `tempo` and `character` like any other `motion` value (`spec:AST-012` FR4); `reducedMotion` is set on the theme itself, since its root mark has no conditional form. - **FR6 — Server rendering gets the same values.** A theme is plain data, and From 13c5cfbc38243dabec1b4762d57b6e964dad83d7 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 00:33:51 -0700 Subject: [PATCH 08/14] spec(AST-108): an extending theme inherits motion field by field, as shipped --- docs/specs/AST-108-theme-motion/spec.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 07efbb7ef1fc9..d674d6adbc90c 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -113,9 +113,8 @@ defineTheme({ unless the theme sets `tempo` itself, so an existing theme renders exactly as before, springs included. Explicit `MotionScaleConfig` fields and explicit `tokens` win over `tempo` and - `character`, and an extending theme's `MotionScaleConfig` fields replace its base's as a - unit, the documented extends rule (`author-a-theme`). Only the three knobs - inherit: a knob the child omits keeps its base's value. An adaptation rule may set `tempo` and `character` like + `character`, and an extending theme inherits field by field: a `MotionScaleConfig` field + or a knob the child leaves unset keeps its base's value. An adaptation rule may set `tempo` and `character` like any other `motion` value (`spec:AST-012` FR4); `reducedMotion` is set on the theme itself, since its root mark has no conditional form. - **FR6 — Server rendering gets the same values.** A theme is plain data, and From e969525ba679c0c37dfa3d9618e5b306f3f04cfb Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 03:31:09 -0700 Subject: [PATCH 09/14] spec(AST-108 +): FR4 scoped to tempo and character; 'always' reaches when.motion rules; nearest provider wins; AST-012 keeps reducedMotion root-owned; impact lists the rest --- docs/design/reduced-motion.md | 11 ++++---- docs/specs/AST-012/spec.md | 3 ++- docs/specs/AST-108-theme-motion/spec.md | 36 ++++++++++++++----------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/design/reduced-motion.md b/docs/design/reduced-motion.md index 929b940935e12..3cbdf7a87f679 100644 --- a/docs/design/reduced-motion.md +++ b/docs/design/reduced-motion.md @@ -54,11 +54,12 @@ trackpad, or keyboard the change is immediate. ## State representation -| State | Required representation | Allowed variation | -| --------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------- | -| reduced motion, coarse pointer | Opacity-only cross-fade of 200 ms or less (DR2); an in-place value change applies at once (DR2) | A shorter cross-fade | -| reduced motion, any other pointer | Immediate change (DR3), except a touch drag's release (DR2) | None | -| no reduced-motion preference | Outside this record | Component and theme motion | +| State | Required representation | Allowed variation | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------- | +| reduced motion, coarse pointer | Opacity-only cross-fade of 200 ms or less (DR2); an in-place value change applies at once (DR2) | A shorter cross-fade | +| reduced motion, any other pointer | Immediate change (DR3), except a touch drag's release (DR2) | None | +| no reduced-motion preference, theme on `'system'` | Outside this record | Component and theme motion | +| a theme on `'always'` (DR2a) | As reduced motion, whatever the person's setting | DR1–DR3 | ## Responsive and input behavior diff --git a/docs/specs/AST-012/spec.md b/docs/specs/AST-012/spec.md index a841f8e5b2666..88ca925050e35 100644 --- a/docs/specs/AST-012/spec.md +++ b/docs/specs/AST-012/spec.md @@ -118,7 +118,8 @@ A breakpoint map alone emits no CSS. Only rules with adaptation values do. `localTokens` or an enrolled exact base declares names. A rule may replace only an exact name already enrolled in the effective lineage and uses AST-006's `TokenValue` normalization and validation. Identity, inheritance, syntax, icons, - indicators, media-surface fields, and adaptations remain root-owned. Within one + indicators, media-surface fields, adaptations, and `motion.reducedMotion` + (`spec:AST-108` FR5) remain root-owned. Within one rule, values use the root expansion, deep-merge, and kind precedence. A generative axis expands against the root axis metadata, and every produced leaf counts as a write by that rule except invariant type-scale component defaults diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 2cad534832046..b2950702b8124 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -96,7 +96,8 @@ defineTheme({ paired with a JavaScript spring, such as MobileNav's close timed from its computed duration and BottomSheet's scrim, whose fade lasts the sheet's computed settle time. -- **FR4 — Some motion is fixed on purpose.** The knobs do not move +- **FR4 — Some motion is fixed on purpose.** `tempo` and `character` do not + move finger-following and drag motion, scroll momentum and decay, the rubber band, the on-screen keyboard's motion (`spec:AST-099` FR8), the press model's clocks (its onset delay, cancel distance, and tap flash), hover-intent @@ -122,9 +123,12 @@ defineTheme({ component styles already key reduced motion on `prefers-reduced-motion`, so the server never guesses and nothing flashes at hydration. `'always'` marks the theme's root, and every component style that keys reduced motion on - `prefers-reduced-motion` also keys it on that mark; the animator, and every script reader of - `prefers-reduced-motion` (a `matchMedia` or `useMediaQuery` check), treat - the preference as set. An internal, + `prefers-reduced-motion` also keys it on that mark; the animator, every script reader of + `prefers-reduced-motion` (a `matchMedia` or `useMediaQuery` check), and every + adaptation rule keyed on `when: {motion}` (`spec:AST-012` FR2) treat the + preference as set. The mark follows the theme-application rule that the + nearest provider wins: inside a nested theme, that theme's own + `reducedMotion`, its `'system'` default included, applies. An internal, pure `resolveThemeMotion(theme)` returns the knobs, durations, easings, and role tables for the animator and for server code; it touches no DOM and has no import-time side effects. The animator reads a person's Reduce Motion @@ -142,8 +146,10 @@ defineTheme({ theme-root mark for `reducedMotion: 'always'` that every component style keying reduced motion on `prefers-reduced-motion` also keys on; Carousel's paging, BottomSheet's panel and sheet gestures, Item's swipe release, - MobileNav, AppShell, and Chat, which read the preference in script, also - honour it. + MobileNav, AppShell, Chat, Markdown's streaming reveal, BottomSheet's + keyboard scroll, and the animated-layout glide, which read the preference in + 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. - Spinner and Skeleton read their loop durations before `tempo` scales them (FR4), so their timing is unchanged under every existing theme. @@ -155,15 +161,15 @@ defineTheme({ ## Verification -| 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 | -| 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 overshoot on a weight-0 role fails | -| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 a knob, or a cross-fade longer than 200 ms, fails | -| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | -| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion` | a DOM access at import or on the server, a missing media-query set, or a hydration change or a component that keeps travelling under `'always'` fails | -| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | +| 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 | +| 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 overshoot on a weight-0 role fails | +| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 a knob, or a cross-fade longer than 200 ms, fails | +| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | +| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that does not apply its own `reducedMotion`, fails | +| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | ## Decision log From 73a10795f8651da64a147a6cb981a8fbc09eb644 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 03:31:45 -0700 Subject: [PATCH 10/14] spec(AST-108): 'always' only adds; an ancestor's reaches nested themes --- docs/specs/AST-108-theme-motion/spec.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index b2950702b8124..a71235ebcc22f 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -126,9 +126,10 @@ defineTheme({ `prefers-reduced-motion` also keys it on that mark; the animator, every script reader of `prefers-reduced-motion` (a `matchMedia` or `useMediaQuery` check), and every adaptation rule keyed on `when: {motion}` (`spec:AST-012` FR2) treat the - preference as set. The mark follows the theme-application rule that the - nearest provider wins: inside a nested theme, that theme's own - `reducedMotion`, its `'system'` default included, applies. An internal, + preference as set. The mark only adds: an ancestor's `'always'` reaches + inside every nested theme, and a nested theme can add `'always'` but never + removes an ancestor's, since nothing loosens `design:reduced-motion` DR1–DR3 + (DR2a). An internal, pure `resolveThemeMotion(theme)` returns the knobs, durations, easings, and role tables for the animator and for server code; it touches no DOM and has no import-time side effects. The animator reads a person's Reduce Motion @@ -161,15 +162,15 @@ defineTheme({ ## Verification -| 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 | -| 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 overshoot on a weight-0 role fails | -| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 a knob, or a cross-fade longer than 200 ms, fails | -| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | -| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that does not apply its own `reducedMotion`, fails | -| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | +| 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 | +| 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 overshoot on a weight-0 role fails | +| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 a knob, or a cross-fade longer than 200 ms, fails | +| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | +| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that restores motion under an ancestor's `'always'`, fails | +| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | ## Decision log From 1b4d396dbc5c2d4244e367541c1e1a05b6c1ed16 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 04:08:25 -0700 Subject: [PATCH 11/14] spec(AST-108 +): theme-application INV1 names the 'always' exception; impact lists AST-012 and INV1; FR4 row scoped --- docs/architecture/theme-application.md | 5 +++-- docs/specs/AST-108-theme-motion/spec.md | 15 +++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/architecture/theme-application.md b/docs/architecture/theme-application.md index b9e978fdf3c60..d8aa72402d992 100644 --- a/docs/architecture/theme-application.md +++ b/docs/architecture/theme-application.md @@ -25,7 +25,7 @@ verified_by: packages/core/src/theme/themeRegistry.test.ts, packages/core/src/AppShell/AppShell.test.tsx, ] -deciding_specs: [spec:AST-012/DEC-1, spec:AST-087/DEC-2] +deciding_specs: [spec:AST-012/DEC-1, spec:AST-087/DEC-2, spec:AST-108/DEC-1] --- # Theme application @@ -95,7 +95,8 @@ standard width map. ## Boundaries and invariants - **INV1 — The nearest provider wins.** Children read the closest Theme context - and CSS scope. + and CSS scope. One exception: a theme's `reducedMotion: 'always'` also reaches + every nested provider (`spec:AST-108` FR6). - **INV2 — Only the root provider changes ``.** Nested providers stay local and cannot change browser chrome or portal-wide theme identity. - **INV3 — Detached consumers share one root-theme store.** A separate React diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index a71235ebcc22f..8e97092792a14 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -11,7 +11,11 @@ approved_at: 2026-10-26 phase: accepted owners: [cixzhang] affects_architecture: - [architecture:theme-tokens, architecture:theme-authoring-contract] + [ + architecture:theme-tokens, + architecture:theme-authoring-contract, + architecture:theme-application, + ] affects_families: [] affects_contributing: [] affects_consumer_docs: [motion, tokens, theme] @@ -157,8 +161,11 @@ defineTheme({ - BottomSheet's sheet and scrim share `--duration-medium`; when the sheet settles on `springs.settle`, its scrim's fade takes the spring's settle time (FR4a). -- `spec:AST-094` FR1, FR4, and DEC-15, `design:reduced-motion` DR2a, the draft - `design:motion` DR3, and `spec:AST-090` are amended to match. +- `spec:AST-094` FR1, FR4, and DEC-15, `design:reduced-motion` DR2a, + `spec:AST-012` FR4 (`reducedMotion` stays root-owned), + `architecture:theme-application` INV1 (`'always'` reaches nested + providers), the draft `design:motion` DR3, and `spec:AST-090` are amended to + match. ## Verification @@ -167,7 +174,7 @@ defineTheme({ | 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 | | 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 overshoot on a weight-0 role fails | | FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, 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 a knob, or a cross-fade longer than 200 ms, fails | +| 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 | | FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | | FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that restores motion under an ancestor's `'always'`, fails | | FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | From 7abce214a097091e8c2df3a90a9a01ff38ea040b Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 04:51:20 -0700 Subject: [PATCH 12/14] spec(AST-108 +): Spinner and Skeleton time loops before tempo; weight-zero roles keep measured bounce; resolver named in theme-authoring-contract --- docs/architecture/theme-authoring-contract.md | 4 ++++ docs/specs/AST-108-theme-motion/spec.md | 17 ++++++++-------- packages/core/src/Skeleton/Skeleton.spec.md | 14 ++++++------- packages/core/src/Spinner/Spinner.spec.md | 20 +++++++++---------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/docs/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index a439149242ec2..8a0c3315c9859 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -249,6 +249,10 @@ public surface belongs to ## Owning code +- An internal, pure `resolveThemeMotion(theme)` returns a theme's motion + knobs, durations, easings, and role tables for the animator and for server + code; it touches no DOM and has no import-time side effects + (`spec:AST-108` FR6). - `packages/core/src/theme/defineTheme.ts` owns the public input, normalized IR, orchestration, and extension behavior. - `themeAdaptations.ts` owns the fixed breakpoint/condition vocabulary, diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 8e97092792a14..a919394d3f38c 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -85,8 +85,9 @@ defineTheme({ `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 - `'expressive'` shift it down and up, so exits, fades, and full-screen moves, - whose weight is zero, never overshoot. With + `'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 `tempo` 1 and `character` `'standard'`, every role plays its measured value exactly. A spring keeps its measured physics, scaled by `tempo` and shaped by `character`; it does not follow a CSS duration or easing twin. The roles @@ -133,10 +134,9 @@ defineTheme({ preference as set. The mark only adds: an ancestor's `'always'` reaches inside every nested theme, and a nested theme can add `'always'` but never removes an ancestor's, since nothing loosens `design:reduced-motion` DR1–DR3 - (DR2a). An internal, - pure `resolveThemeMotion(theme)` returns the knobs, durations, easings, and - role tables for the animator and for server code; it touches no DOM and has - no import-time side effects. The animator reads a person's Reduce Motion + (DR2a). The server and the client resolve the same + motion values from a theme, and importing the theme and motion modules + touches no DOM (`architecture:theme-authoring-contract`). The animator reads a person's Reduce Motion setting only when an animation starts, on the client. - **FR7 — Docs ship with it.** The motion, duration-token, and easing-token docs, the theme template, the theme-authoring guide, and @@ -157,7 +157,8 @@ defineTheme({ (`spec:AST-012`). - `design:reduced-motion` gains DR2a: a theme may reduce motion for everyone. - Spinner and Skeleton read their loop durations before `tempo` scales them - (FR4), so their timing is unchanged under every existing theme. + (FR4), so their timing is unchanged under every existing theme; + `component:Spinner` FR3 and `component:Skeleton` FR4 say so. - BottomSheet's sheet and scrim share `--duration-medium`; when the sheet settles on `springs.settle`, its scrim's fade takes the spring's settle time (FR4a). @@ -172,7 +173,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 | -| 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 overshoot on a weight-0 role 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, 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 | | FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | diff --git a/packages/core/src/Skeleton/Skeleton.spec.md b/packages/core/src/Skeleton/Skeleton.spec.md index 141bd3e665ebd..18ffaccadd1cd 100644 --- a/packages/core/src/Skeleton/Skeleton.spec.md +++ b/packages/core/src/Skeleton/Skeleton.spec.md @@ -84,13 +84,13 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| FR1 | Skeleton renders one childless `div` carrying the `skeleton` target, sized by `width` and `height` (numbers in pixels, strings as CSS), default `100%` each. | Current source and tests | -| FR2 | `radius` maps `none` to no radius, `0` to `--radius-none`, `1` to `--radius-inner`, `2` to `--radius-element`, `3` and `4` to `--radius-container`, and `rounded` to `--radius-full`; the default is `3`. | Current source | -| FR3 | The block paints `--color-skeleton` at opacity 0.25. Under `prefers-contrast: more` it paints that color mixed 30% toward primary text. Under `forced-colors: active` it paints `GrayText`, which wins over the contrast rule, at full opacity while no pulse runs; with motion allowed the FR4 pulse still varies its opacity. | Current source and tests | -| FR4 | The block pulses its opacity from 0.25 to 0.925 in ten equal held steps and back, alternating indefinitely, each climb and each fall taking `--duration-medium-max`, with a linear timing function and no `steps()`. The pulse starts after 1000 ms plus 100 ms times `index`. Under `prefers-reduced-motion: reduce` there is no pulse. | Current source and tests | -| FR5 | `ref` reaches the block. Supported DOM, ARIA, data, and event props, including `data-testid`, reach the block after the default `aria-hidden`; `xstyle`, `className`, and `style` merge in that order after component styles. | Current source and tests | +| ID | Invariant | Basis | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| FR1 | Skeleton renders one childless `div` carrying the `skeleton` target, sized by `width` and `height` (numbers in pixels, strings as CSS), default `100%` each. | Current source and tests | +| FR2 | `radius` maps `none` to no radius, `0` to `--radius-none`, `1` to `--radius-inner`, `2` to `--radius-element`, `3` and `4` to `--radius-container`, and `rounded` to `--radius-full`; the default is `3`. | Current source | +| FR3 | The block paints `--color-skeleton` at opacity 0.25. Under `prefers-contrast: more` it paints that color mixed 30% toward primary text. Under `forced-colors: active` it paints `GrayText`, which wins over the contrast rule, at full opacity while no pulse runs; with motion allowed the FR4 pulse still varies its opacity. | Current source and tests | +| FR4 | The block pulses its opacity from 0.25 to 0.925 in ten equal held steps and back, alternating indefinitely, each climb and each fall taking `--duration-medium-max` as the theme sets it before `tempo` scales it (`spec:AST-108` FR4), with a linear timing function and no `steps()`. The pulse starts after 1000 ms plus 100 ms times `index`. Under `prefers-reduced-motion: reduce` there is no pulse. | Current source and tests | +| FR5 | `ref` reaches the block. Supported DOM, ARIA, data, and event props, including `data-testid`, reach the block after the default `aria-hidden`; `xstyle`, `className`, and `style` merge in that order after component styles. | Current source and tests | ### Allowed variation diff --git a/packages/core/src/Spinner/Spinner.spec.md b/packages/core/src/Spinner/Spinner.spec.md index 9c7bbfe6684dc..a997f449c6947 100644 --- a/packages/core/src/Spinner/Spinner.spec.md +++ b/packages/core/src/Spinner/Spinner.spec.md @@ -108,16 +108,16 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| FR1 | Without a label, Spinner renders one inline-grid `span` status element that carries the `spinner` target, ref, `data-testid`, and passthrough props. With a label, a column `div` wrapper with `--spacing-2` gap carries the target, ref, `data-testid`, passthrough props, and styling inputs; the status `span` and then the label render inside it. | Current source and tests | -| FR2 | The status element's box is the ring diameter plus one stroke width on each side, set inline after the caller's `style`, so a caller `width` or `height` never resizes it while other caller styles still apply. The box does not shrink in a flex host. With no stylesheet, the box and ring draw at the size's default geometry. | Current source and tests | -| FR3 | The ring is an `aria-hidden` SVG of two concentric circles: a full track and an arc covering `--spinner-arc-fraction` of the circumference (0.375 by default), starting at twelve o'clock with round caps. The arc's dash moves around the stationary circle at `--duration-slow-min` per cycle, linear, indefinitely; under `prefers-reduced-motion: reduce` the cycle slows to 3 seconds and does not stop. | Current source and tests | -| FR4 | All mounted rings turn in the same phase, whenever each mounted. Where the browser cannot report running animations, Spinner does no phase work. | Current source and tests | -| FR5 | `default` paints `--color-accent` on `--color-track`; `subtle` paints `--color-text-secondary` on `--color-track`; `onMedia` paints `--color-on-dark` on itself at track opacity 77/255; `inherit` paints `currentColor` on itself at track opacity 0.3. | Current source and tests | -| FR6 | The five public variables are declared only on the element carrying the `spinner` target, per `size` and `shade`, so a theme rule on that target and its `size:` or `shade:` variants overrides them; the inner status element inherits them when a label moves the target to the wrapper. | Current source and tests | -| FR7 | A string label renders as bold body Text; a rich-node label renders as supplied. | Current source and tests | -| FR8 | `xstyle` and `className` reach the element carrying the target and merge after component styles; `style` merges after them except for the status box's `width` and `height` (FR2). | Current source and tests | +| ID | Invariant | Basis | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| FR1 | Without a label, Spinner renders one inline-grid `span` status element that carries the `spinner` target, ref, `data-testid`, and passthrough props. With a label, a column `div` wrapper with `--spacing-2` gap carries the target, ref, `data-testid`, passthrough props, and styling inputs; the status `span` and then the label render inside it. | Current source and tests | +| FR2 | The status element's box is the ring diameter plus one stroke width on each side, set inline after the caller's `style`, so a caller `width` or `height` never resizes it while other caller styles still apply. The box does not shrink in a flex host. With no stylesheet, the box and ring draw at the size's default geometry. | Current source and tests | +| FR3 | The ring is an `aria-hidden` SVG of two concentric circles: a full track and an arc covering `--spinner-arc-fraction` of the circumference (0.375 by default), starting at twelve o'clock with round caps. The arc's dash moves around the stationary circle at `--duration-slow-min` per cycle, as the theme sets it before `tempo` scales it (`spec:AST-108` FR4), linear, indefinitely; under `prefers-reduced-motion: reduce` the cycle slows to 3 seconds and does not stop. | Current source and tests | +| FR4 | All mounted rings turn in the same phase, whenever each mounted. Where the browser cannot report running animations, Spinner does no phase work. | Current source and tests | +| FR5 | `default` paints `--color-accent` on `--color-track`; `subtle` paints `--color-text-secondary` on `--color-track`; `onMedia` paints `--color-on-dark` on itself at track opacity 77/255; `inherit` paints `currentColor` on itself at track opacity 0.3. | Current source and tests | +| FR6 | The five public variables are declared only on the element carrying the `spinner` target, per `size` and `shade`, so a theme rule on that target and its `size:` or `shade:` variants overrides them; the inner status element inherits them when a label moves the target to the wrapper. | Current source and tests | +| FR7 | A string label renders as bold body Text; a rich-node label renders as supplied. | Current source and tests | +| FR8 | `xstyle` and `className` reach the element carrying the target and merge after component styles; `style` merges after them except for the status box's `width` and `height` (FR2). | Current source and tests | ### Allowed variation From 6b9471233f3ff7706f99e46afed1350e63a7ffd1 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 10:15:24 -0700 Subject: [PATCH 13/14] Merge main into spec/theme-motion-knobs --- docs/architecture/theme-application.md | 2 ++ docs/specs/AST-094-touch-presentations/spec.md | 4 ++-- docs/specs/AST-108-theme-motion/spec.md | 3 ++- packages/core/src/Button/Button.spec.md | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/architecture/theme-application.md b/docs/architecture/theme-application.md index d8aa72402d992..d7c38f99d58ff 100644 --- a/docs/architecture/theme-application.md +++ b/docs/architecture/theme-application.md @@ -160,6 +160,8 @@ which component parts are public theme targets. - `spec:AST-012/DEC-1` owns the fixed width-point vocabulary AppShell consumes. - `spec:AST-087/DEC-2` makes theme icon entries functions, so a theme holding them reaches `Theme` from client code. +- `spec:AST-108/DEC-1` lets a theme's `reducedMotion: 'always'` reach nested + providers (INV1). ## Verification diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 45ad7c452505c..5e0e66f3ecd2a 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -122,7 +122,7 @@ a measured, sourced difference exists. the keyboard's own motion (`spec:AST-099` FR8, `architecture:react-component-runtime` INV11). Gesture thresholds, the timings that decide who gets a touch (the content-touch delay, the scroll brake, momentum memory) and haptics are not - motion: they stay internal measured values and are not public concepts (`spec:AST-002` FR3). The three knobs are the motion configuration of + motion: they stay internal measured values and are not public concepts (`spec:AST-002` FR3). The three knobs join the motion configuration of `architecture:theme-authoring-contract` and `architecture:theme-tokens` (`spec:AST-108`). It departs from the draft `design:motion` DR3 wherever the native motion settles with an overshoot or stretches with a @@ -619,7 +619,7 @@ outside a menu closes it. | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | FR1, FR2 | Value check over every measured value and its uses | every value; a guess and its measurement task; a platform-specific value; two components playing the same motion | an unsourced value, an unknown source kind, a guess without a listed measurement, an unsourced platform split, or two values for one use fails | | FR3 | Unit tests of the spring, decay, rubber band and projection math against an independent integration and values read from UIKit | under-, critically and overdamped springs; release velocities; both rates; overscroll depths | changing a formula or a constant breaks the agreement with UIKit's values | -| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table, or a theme that changes a role other than through its motion knobs fails | +| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table at `tempo` 1 and `'standard'`, or a theme that changes a role other than through its motion knobs fails | | FR5 | Browser proof: an animation keeps advancing frame by frame through a 1 s main-thread block, with no layout or paint per frame | Chromium phone emulation; WebKit | a script-driven or layout-animating implementation freezes or lays out | | FR6 | Unit and browser tests catching an animation mid-flight and retargeting it | catch at peak velocity; retarget with a new spring | a layout read, a jump, or a lost velocity fails | | FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), or an opacity fade over 200 ms fails | diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index a919394d3f38c..5933b216397de 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -165,7 +165,8 @@ defineTheme({ - `spec:AST-094` FR1, FR4, and DEC-15, `design:reduced-motion` DR2a, `spec:AST-012` FR4 (`reducedMotion` stays root-owned), `architecture:theme-application` INV1 (`'always'` reaches nested - providers), the draft `design:motion` DR3, and `spec:AST-090` are amended to + providers), `architecture:theme-authoring-contract` (the internal motion resolver), + `component:Button` FR15 (its release fade follows `tempo`), the draft `design:motion` DR3, and `spec:AST-090` are amended to match. ## Verification diff --git a/packages/core/src/Button/Button.spec.md b/packages/core/src/Button/Button.spec.md index c3bc2b625d548..f915b54b4221d 100644 --- a/packages/core/src/Button/Button.spec.md +++ b/packages/core/src/Button/Button.spec.md @@ -131,7 +131,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR12 | With `tooltip`, the tooltip attaches to the Button element itself (no wrapper element), opens above it, and its content is added to `aria-describedby` after any caller value. | Current source; real-browser accessibility test | | FR13 | A standalone Button paints the shadow token for its `elevation`. Inside a ButtonGroup it paints no shadow and reflects `data-elevation="none"`, whatever `elevation` says. | `family:buttons` FR8–FR10; current source and tests | | FR14 | The root carries `astryx-button` and reflects the effective `data-variant`, `data-size`, and `data-elevation`. | `family:buttons` FR10; `architecture:component-theming-surface`; tests | -| FR15 | Hover paints the shared interaction overlay on hover-capable pointers, and a press paints the shared pressed overlay through the press controller: at once under a mouse or a pen (`spec:AST-094` FR11's fine-pointer profile) and on FR11's control profile under a finger, fading out over 200 ms on release. Without a reduced-motion preference, a standalone Button also scales to 0.98 on a mouse press, or while the touch press reads on; under reduced motion it MUST NOT scale (`design:reduced-motion` DR1). All of this paint follows the element's exposed disabled state: a Button that is natively disabled or exposes `aria-disabled="true"` (busy-only, disabled with a tooltip, or a caller's own `aria-disabled`) shows no overlay and no press scale, even when it stays operable. Inside a ButtonGroup the press scale does not apply. Button's hover and press transitions are fast, and instant under reduced motion. Implementation gap: the 0.98 press scale also applies under reduced motion; only its transition becomes instant. | Current source and tests; `spec:AST-094` FR11 and DEC-21 for the mouse and pen press | +| FR15 | Hover paints the shared interaction overlay on hover-capable pointers, and a press paints the shared pressed overlay through the press controller: at once under a mouse or a pen (`spec:AST-094` FR11's fine-pointer profile) and on FR11's control profile under a finger, fading out over 200 ms on release at `tempo` 1, scaled by the theme's `tempo` (`spec:AST-108` FR4). Without a reduced-motion preference, a standalone Button also scales to 0.98 on a mouse press, or while the touch press reads on; under reduced motion it MUST NOT scale (`design:reduced-motion` DR1). All of this paint follows the element's exposed disabled state: a Button that is natively disabled or exposes `aria-disabled="true"` (busy-only, disabled with a tooltip, or a caller's own `aria-disabled`) shows no overlay and no press scale, even when it stays operable. Inside a ButtonGroup the press scale does not apply. Button's hover and press transitions are fast, and instant under reduced motion. Implementation gap: the 0.98 press scale also applies under reduced motion; only its transition becomes instant. | Current source and tests; `spec:AST-094` FR11 and DEC-21 for the mouse and pen press | | FR16 | The ghost variant carries the edge-compensation marker so an enclosing container can align it flush with its edge; other variants do not. | Current source; edge-compensation tests | | FR17 | `ref`, `className`, `style`, `xstyle`, and other DOM props reach the root element. A caller's own `aria-busy` and `aria-disabled` are kept beside Button's own (either one sets the attribute); a caller `aria-disabled` changes exposure and paint only and does not make the Button inert. The tooltip trigger shares the caller's `ref`, which stays attached across rerenders. | Current source and tests | From b84e8f2a5c9731882c07fe1d2c48452c48a4118c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 11:03:09 -0700 Subject: [PATCH 14/14] Merge main into spec/theme-motion-knobs --- docs/architecture/theme-authoring-contract.md | 10 ++++++---- docs/specs/AST-108-theme-motion/spec.md | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index 8a0c3315c9859..f02be197843d7 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -249,10 +249,6 @@ public surface belongs to ## Owning code -- An internal, pure `resolveThemeMotion(theme)` returns a theme's motion - knobs, durations, easings, and role tables for the animator and for server - code; it touches no DOM and has no import-time side effects - (`spec:AST-108` FR6). - `packages/core/src/theme/defineTheme.ts` owns the public input, normalized IR, orchestration, and extension behavior. - `themeAdaptations.ts` owns the fixed breakpoint/condition vocabulary, @@ -289,6 +285,12 @@ classify local references. Until the implementation lands, INV8's prefix-indepen clauses are current authority but not enforcement. Existing explicit enrollment, owner, lineage, collision, cycle, and legacy-unenrolled behavior remains shipped. +The motion resolver is accepted but unshipped: an internal, pure +`resolveThemeMotion(theme)` returns a theme's motion knobs, durations, easings, +and role tables for the animator and for server code, touching no DOM and with +no import-time side effects (`spec:AST-108` FR6). Until it lands, FR6's +resolution is current authority but not enforcement. + ## Verification | Invariant | Evidence | Failure signal | diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 5933b216397de..1a6adbeb91854 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -63,7 +63,7 @@ defineTheme({ - `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 + picks the entrance and move curves and the spring bounce. 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 @@ -96,8 +96,8 @@ defineTheme({ together start and land together: they play the same motion role, or the CSS part is timed from the spring's computed settle time, and never as two independently timed curves. This covers an element and the siblings that - glide around it (`spec:AST-105`'s one shared timing), `presence`'s enter and - exit, a sheet and its scrim, a menu's grow and its content, and a CSS fade + glide around it (`spec:AST-105`'s one shared timing), an element's + entrance and its exit, a sheet and its scrim, a menu's grow and its content, and a CSS fade paired with a JavaScript spring, such as MobileNav's close timed from its computed duration and BottomSheet's scrim, whose fade lasts the sheet's computed settle time. @@ -175,7 +175,7 @@ defineTheme({ | -------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 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 | | 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, 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 | +| 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 | | FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | | FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that restores motion under an ancestor's `'always'`, fails |