diff --git a/docs/architecture/theme-application.md b/docs/architecture/theme-application.md index b9e978fdf3c60..d7c38f99d58ff 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 @@ -159,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/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index a439149242ec2..f02be197843d7 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -285,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/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 00f3edeaaac81..31995ac89528e 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 @@ -75,6 +76,10 @@ trackpad, or keyboard the change is immediate. under Reduce Motion. An in-place value change, such as a progress fill's length or another continuous value, does not cross-fade: under reduced motion it applies at once on any pointer, with no added element (DEC-3). +- **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-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-090-exit-motion/spec.md b/docs/specs/AST-090-exit-motion/spec.md index dcf138257c292..0fc309ed77bdc 100644 --- a/docs/specs/AST-090-exit-motion/spec.md +++ b/docs/specs/AST-090-exit-motion/spec.md @@ -101,7 +101,10 @@ 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-exit-layer` default to `0ms`. An exit duration a theme sets as a + reference to a tempo-scaled token, such as FR12's `var(--duration-exit)`, + follows the theme's tempo; one set as a literal plays as written + (`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 2bdaf010cac3b..5e0e66f3ecd2a 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -65,8 +65,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). @@ -107,18 +107,24 @@ 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 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, the press model's onset delay, cancel distance, and tap flash + (`spec:AST-108` FR4), 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 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 rubber band. @@ -546,8 +552,8 @@ outside a menu closes it. ## 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 @@ -613,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 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 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 | @@ -650,8 +656,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. @@ -673,8 +679,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 @@ -787,6 +793,18 @@ 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, and DEC-1's and DEC-3's per-role +retuning, which a theme does through the knobs; DEC-11's rejected alternative, public +per-component roles, stays rejected. + ### DEC-21 — Gestures work on the web, not only under a finger **Reference:** `spec:AST-094/DEC-21` 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..1a6adbeb91854 --- /dev/null +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -0,0 +1,202 @@ +--- +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, + architecture:theme-application, + ] +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. 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 + only the job-named `--ease-entrance` and `--ease-move` tokens, declared in + `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 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 + `'expressive'` are a decelerate-only curve and a curve with a slight + overshoot. +- **FR3 — The animator's tables derive from the same knobs.** The animator's + base tables, every role in `spec:AST-094` FR4, derive from the theme's + `tempo` and `character` at their source: a spring's duration scales by + `tempo`, and `character` offsets the role's measured bounce by an amount + times the role's fixed weight: `'standard'` adds nothing, and `'crisp'` and + `'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 + 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), 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. +- **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 + delays, gesture thresholds (`spec:AST-002` FR3), the loop periods of busy + indicators such as Spinner and Skeleton, which read the duration their theme + sets before `tempo` scales it, so a `MotionScaleConfig` still times them, + 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 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 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 + 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, 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 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). 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 + `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`, and a + 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, 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; + `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). +- `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), `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 + +| 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 a weight-0 role whose bounce changes with `character` fails | +| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, an element's entrance and exit, a sheet and its scrim, a menu's grow and content, a CSS fade with a spring | paired animations that end more than one frame apart fail | +| FR4 | Unit tests of each fixed item | finger tracking, momentum, rubber band, keyboard, press and hover clocks, loops, Reduce Motion cap; Spinner and Skeleton loops at each tempo | a fixed item that moves with `tempo` or `character`, or a cross-fade longer than 200 ms, fails | +| 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 + +### 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 character steps, not a continuous bounce, +are the recommendation adopted with the knobs. 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. 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 | 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