From c3831c0d43f08486471e2fa55f7fe6741a240f15 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 29 Oct 2026 19:27:28 -0700 Subject: [PATCH 1/6] feat(core): a theme's motion takes tempo, character and reducedMotion Rebased onto main as one change from the theme motion knobs branch (spec:AST-108 FR1-FR7): tempo scales every motion duration, character sets the --ease-entrance and --ease-move curves, and reducedMotion marks the theme's root. Spinner's and Skeleton's loops keep their timing under every tempo, the press release fade follows tempo up to the Reduce Motion cap, adaptation rules take tempo and character, an extending theme inherits motion field by field, and theme build checks a defined theme's authored tokens and rules. The motion, token and theme-authoring docs, the theme template and the authoring contract describe the knobs. --- .changeset/theme-motion-knobs.md | 8 + docs/architecture/theme-authoring-contract.md | 44 +- .../cli/assets/docs/author-a-theme.doc.mjs | 6 +- packages/cli/assets/docs/motion.doc.mjs | 41 +- .../assets/docs/tree/tokens-duration.doc.mjs | 4 +- .../cli/assets/docs/tree/tokens-ease.doc.mjs | 12 +- packages/cli/assets/docs/tree/tokens.doc.mjs | 2 +- packages/cli/assets/theme.template.ts | 23 +- .../foundation/config/theme-private-vars.mjs | 32 +- .../config/theme-private-vars.test.mjs | 21 + .../__golden__/no-icon-set/docs-topics.json | 3 +- .../__golden__/no-icon-set/docs-tree.json | 1 + .../no-icon-set/search-icon-set.json | 2 +- .../no-icon-set/search-icon-set.txt | 2 +- packages/core/src/Skeleton/Skeleton.tsx | 4 +- packages/core/src/Spinner/Spinner.tsx | 4 +- packages/core/src/__tests__/pressState.ts | 12 +- packages/core/src/theme/Theme.doc.mjs | 2 +- packages/core/src/theme/Theme.tsx | 38 +- packages/core/src/theme/defineTheme.ts | 95 ++- packages/core/src/theme/generateThemeRules.ts | 50 +- packages/core/src/theme/motionKnobs.ts | 85 +++ packages/core/src/theme/resolveThemeValues.ts | 74 ++- .../core/src/theme/themeAdaptations.test.ts | 80 +++ packages/core/src/theme/themeAdaptations.ts | 88 ++- packages/core/src/theme/themeMotion.test.ts | 578 ++++++++++++++++++ packages/core/src/theme/themeMotion.ts | 205 +++++++ .../core/src/theme/themeMotionMark.test.tsx | 201 ++++++ packages/core/src/theme/tokens.stylex.ts | 10 + .../src/utils/interactionOverlay.stylex.ts | 16 +- packages/core/src/utils/pressFeedback.test.ts | 183 ++++++ packages/core/src/utils/pressFeedback.ts | 47 +- scripts/build-css.test.mjs | 23 +- scripts/generate-token-docs.mjs | 5 +- 34 files changed, 1899 insertions(+), 102 deletions(-) create mode 100644 .changeset/theme-motion-knobs.md create mode 100644 packages/core/src/theme/motionKnobs.ts create mode 100644 packages/core/src/theme/themeMotion.test.ts create mode 100644 packages/core/src/theme/themeMotion.ts create mode 100644 packages/core/src/theme/themeMotionMark.test.tsx diff --git a/.changeset/theme-motion-knobs.md b/.changeset/theme-motion-knobs.md new file mode 100644 index 0000000000000..f452a34c1504a --- /dev/null +++ b/.changeset/theme-motion-knobs.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] A theme's `motion` takes three knobs beside the duration scale: `tempo` (0.75–1.5) scales every motion duration, `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the new `--ease-entrance` and `--ease-move` tokens, and `reducedMotion: 'always'` reduces motion for everyone; so far the theme's motion-keyed adaptation rules and the press release fade follow it, not yet components' other Reduce Motion styles. A theme without them renders exactly as before; a duration scale and explicit tokens win over the knobs, Spinner's and Skeleton's loops keep their timing under every tempo, and the press release fade follows tempo up to the 200 ms Reduce Motion cap, under the person's setting or the theme's `'always'`. Springs don't read the knobs yet. The motion, token and theme-authoring docs and the theme template describe the knobs. + +@vjeux diff --git a/docs/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index f02be197843d7..7f1f5f60924e3 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -18,6 +18,8 @@ applies_to: packages/core/src/theme/expandTypeScale.ts, packages/core/src/theme/expandRadiusScale.ts, packages/core/src/theme/expandMotionScale.ts, + packages/core/src/theme/themeMotion.ts, + packages/core/src/theme/motionKnobs.ts, packages/core/src/theme/mergeComponents.ts, packages/core/src/theme/onMediaTokens.ts, packages/core/src/theme/localTokens.ts, @@ -33,6 +35,7 @@ verified_by: packages/core/src/theme/expandTypeScale.test.ts, packages/core/src/theme/expandRadiusScale.test.ts, packages/core/src/theme/expandMotionScale.test.ts, + packages/core/src/theme/themeMotion.test.ts, packages/core/src/theme/onMediaTokens.test.ts, scripts/check-theme-template.test.mjs, ] @@ -52,6 +55,7 @@ deciding_specs: spec:AST-087/DEC-7, spec:AST-087/DEC-9, spec:AST-087/DEC-10, + spec:AST-108/DEC-1, ] --- @@ -192,6 +196,20 @@ it, typed as that theme's listed names. - **INV14 — The variant list is theme-level.** `iconVariants` lists the variant names the whole theme supports. It is never per icon and never combined with another theme's list. +- **INV15 — Motion is three knobs beside the duration scale.** `motion` takes + `tempo` (clamped to 0.75–1.5), `character` (`crisp`, `standard`, + `expressive`), and `reducedMotion` (`system`, `always`) beside the duration + scale. `tempo` scales every duration token and `character` sets + `--ease-entrance` and `--ease-move`; with tempo 1 and `standard` the tokens + are the defaults. A duration scale's durations win over `tempo`, and + explicit tokens win over both. An extending theme inherits motion field by + field: a duration-scale field or knob the child leaves unset keeps its + base's value. Each duration's + value before `tempo` is kept for busy-indicator loops, which never follow + `tempo`. `reducedMotion: 'always'` marks the theme's root, and what keys + reduced motion on `prefers-reduced-motion` keys it on that mark as well + (`spec:AST-108` FR6). An adaptation rule may set `tempo` and `character`, + never `reducedMotion`. This record does not own: @@ -249,6 +267,9 @@ public surface belongs to ## Owning code +- An internal, pure `resolveThemeMotion(theme)` returns a theme's motion + knobs, durations, and easings 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, @@ -257,6 +278,9 @@ public surface belongs to adopts their tokens. - `expandColorScale.ts`, `expandTypeScale.ts`, `expandRadiusScale.ts`, and `expandMotionScale.ts` own their derived token/component values. +- `motionKnobs.ts` owns the motion knobs' types, defaults and clamping; + `themeMotion.ts` owns the tokens and pre-tempo durations they generate, and + `resolveThemeMotion`, the pure read the animator and server code use. - `mergeComponents.ts` owns the shared deep-merge rule. - `onMediaTokens.ts` owns normalization of inverted-surface overrides. - `localTokens.ts` owns opt-in enrollment, valid CSS custom-property names, exact @@ -275,7 +299,9 @@ behavior. AST-012 decisions 1–4 establish the fixed width map, named closed co ordered rule cascade, and source/built adaptation metadata parity. AST-017 decision 1 owns released compatibility classification and migration. AST-087 decisions 2, 7, 9, and 10 establish function icon entries, node-valued public reads, the theme-level -`iconVariants` list, and the static public view of `componentIcons`. +`iconVariants` list, and the static public view of `componentIcons`. AST-108 +decision 1 establishes the three motion knobs and keeps per-role motion values +internal. ## Known conformance and verification gaps @@ -285,11 +311,16 @@ 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. +`resolveThemeMotion`'s role tables are accepted but unshipped (`spec:AST-108` +FR6): no spring reads the motion knobs yet. Until they land, that part of FR6 +is current authority but not enforcement. + +The reach of `reducedMotion: 'always'` is accepted but partly unshipped +(`spec:AST-108` FR6, INV15). Motion-keyed adaptation rules and the press +release fade key reduced motion on the mark; components' other Reduce Motion +styles, the animator, and other script readers of `prefers-reduced-motion` do +not yet. Until they do, that part of INV15 and FR6 is current authority but not +enforcement. ## Verification @@ -304,5 +335,6 @@ resolution is current authority but not enforcement. | INV11 | `DefineThemeInput`/output diff plus runtime/build fixtures | Validation-only data enters the normalized theme, or productive input loses construction validation | | INV12 | Core theme export diff, constructed-value evidence, and current-consumer callsite | A theme `define*` helper only checks input and returns that exact input unchanged | | INV13, INV14 | `defineTheme.test.ts` type and normalization tests for function entries, slot functions, and the list | A public read returns a function, a child merges variant lists, or an entry sees another theme's names | +| INV15 | `themeMotion.test.ts` over tempo bounds, characters, a duration scale, explicit tokens, and extension | A token off its knob, a knob beating a scale or explicit token, a lost inherited knob, or a loop following `tempo` | | Theme/Core compatibility | Maintained theme source/build against the minimum Core in its currently supported peer/dependency range | A theme update silently requires newer in-range Core or bypasses the `spec:AST-017` path | | Authoring projection | `scripts/check-theme-template.test.mjs` | A supported authoring concept is missing or misstated in the template | diff --git a/packages/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index 23871d1880d41..273aa7ecd7ca4 100644 --- a/packages/cli/assets/docs/author-a-theme.doc.mjs +++ b/packages/cli/assets/docs/author-a-theme.doc.mjs @@ -41,7 +41,7 @@ export const docs = { ['typography.scale', '--text-heading-*-size/weight/leading, --text-body-size/weight/leading', 'base (px), ratio'], ['typography.body/heading/code', '--font-family-body, --font-family-heading, --font-family-code', 'family, fallbacks?, url?, weight?'], ['radius', '--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat', 'base (px), multiplier (0–2)'], - ['motion', '--duration-fast-min/fast/fast-max, --duration-medium-min/medium/medium-max', 'fast (ms), medium (ms), ratio, easing?'], + ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system|always); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets. See {@link generic:motion}.'], ]}, {type: 'prose', text: 'For dark mode tuples in token values, see the Dark mode section of {@link generic:use-a-theme}.'}, ], @@ -58,7 +58,7 @@ export const docs = { ['iconVariants', 'Replaced, never merged: a child that sets iconVariants replaces the base list; a child that omits it inherits the list.'], ['indicators', 'Shallow-merged: child indicators override matching names from the base.'], ['onDark, onLight', "Deep-merged per surface: the base's resolved surface first, then the child's overrides."], - ['motion', "Field by field: the child's `fast`, `medium` and `ratio` regenerate the fast and medium duration tokens; `slow` and `easing` left unset keep the base theme's tokens, so an inherited slow band stays at the base's ratio."], + ['motion', "Field by field: the child's `fast`, `medium` and `ratio` regenerate the fast and medium duration tokens; `slow` and `easing` left unset keep the base theme's tokens, so an inherited slow band stays at the base's ratio. Each knob (`tempo`, `character`, `reducedMotion`) the child leaves unset keeps the base's."], ['typography, radius, color', 'Child config replaces base entirely (these are scale inputs, not additive).'], ['adaptations', 'Width-breakpoint overrides merge by fixed name. Inherited ordered rules keep their relative order; child rules append and re-resolve against the child root axes.'], ]}, @@ -106,7 +106,7 @@ export const docs = { {type: 'prose', text: "Precedence follows rule order. Root theme values apply first, then every matching rule in declaration order. A later rule may deliberately restore a root value. `onDark` and `onLight` media-surface overrides apply after adaptations and win on the same leaf."}, {type: 'prose', text: "`extends` preserves the base rule order and appends child rules. Inherited conditions use the child's effective breakpoint map. An empty child rule is a no-op, not a removal operator."}, {type: 'prose', text: "A rule may replace a theme-local token only when the exact name is already enrolled by root `localTokens` or an enrolled base. Component writes in a rule are validated exactly like root `components`: same targets, axes, and value domains. The one addition is that a rule may not be the only place a custom value is enrolled: a value that is valid only because a theme enrolls it generates unconditional type augmentation, so declare it on the root theme first and let rules restyle it. Built-in values need no root declaration. When rules can match together, their ordered portable and local token writes are validated as one effective graph; any reachable cycle fails before CSS is emitted."}, - {type: 'prose', text: 'Adaptations compile to CSS media queries with no resize listener or styling rerender. Runtime and `{@command theme build}` use the same compiler, but only a built theme is present at first paint in an SSR app.'}, + {type: 'prose', text: "Adaptations compile to CSS media queries with no resize listener or styling rerender. Runtime and `{@command theme build}` use the same compiler, but only a built theme is present at first paint in an SSR app. Under `reducedMotion: 'always'`, the theme's own or an enclosing theme's, a `motion: 'reduce'` rule applies whatever the person's setting, and a `motion: 'no-preference'` rule does not."}, ], }, { diff --git a/packages/cli/assets/docs/motion.doc.mjs b/packages/cli/assets/docs/motion.doc.mjs index a99a9282bb4f7..14b015a6dc4e6 100644 --- a/packages/cli/assets/docs/motion.doc.mjs +++ b/packages/cli/assets/docs/motion.doc.mjs @@ -120,7 +120,7 @@ export const docs = { content: [ { type: 'prose', - text: 'Set transition durations and easing from the motion tokens, so a theme that retunes motion retunes your animations too.', + text: "Set transition durations and easing from the motion tokens, so a theme that retunes motion retunes your animations too. Ease something arriving on `--ease-entrance` and something moving on screen on `--ease-move`, so it follows the theme's `character`.", }, { type: 'code', @@ -137,12 +137,49 @@ const styles = stylex.create({ slideUp: { transitionProperty: 'transform, opacity', transitionDuration: durationVars['--duration-medium'], - transitionTimingFunction: easeVars['--ease-standard'], + transitionTimingFunction: easeVars['--ease-entrance'], }, });`, }, ], }, + { + id: 'theme-motion', + title: 'Tune motion from the theme', + category: 'foundations', + content: [ + { + type: 'prose', + text: "A theme sets how motion feels with three knobs in `defineTheme`'s `motion`. They drive the CSS motion tokens; the springs of touch motion don't read them yet.", + }, + { + type: 'list', + style: 'unordered', + items: [ + "`tempo` scales every motion duration: 1 is Astryx's own, 0.75 the quickest and 1.5 the slowest (values outside are clamped).", + "`character` sets how lively motion is: `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, and `'expressive'` overshoots slightly on entrances and moves. Exits and fades never overshoot.", + "`reducedMotion: 'always'` reduces motion for everyone, as if each person had turned on Reduce Motion. Not everything follows it yet: the theme's motion-keyed adaptation rules and the press release fade do, and components' other Reduce Motion styles don't yet. `'system'`, the default, follows each person's setting. No value loosens Reduce Motion.", + ], + }, + { + type: 'code', + lang: 'ts', + label: 'A quicker, crisper theme', + code: `export const brandTheme = defineTheme({ + name: 'brand', + motion: {tempo: 0.85, character: 'crisp'}, +});`, + }, + { + type: 'prose', + text: "Some motion never follows the knobs, because it tracks a finger or the system: finger tracking, momentum and the rubber band, the on-screen keyboard, the press model's onset delay, cancel distance and tap flash (its release fade follows tempo), hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cross-fade.", + }, + { + type: 'prose', + text: 'Precedence, lowest to highest: the knobs, then a duration scale (`fast`, `medium`, `slow`, `ratio`) for the durations it sets, then explicit `tokens`. A theme with no `motion` renders exactly as before.', + }, + ], + }, { title: 'Best Practices', category: 'foundations', diff --git a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs index 917328cf73fbf..b9b4fecae30f3 100644 --- a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 100 }, "category": "foundations", - "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio.", + "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it.", "sections": [ { "title": "Duration Tokens", "content": [ { "type": "prose", - "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio." + "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it." }, { "type": "table", diff --git a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs index ed95f3bde5b80..213583f88b1f5 100644 --- a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 110 }, "category": "foundations", - "description": "Easing curves for animations and transitions.", + "description": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character.", "sections": [ { "title": "Easing Tokens", "content": [ { "type": "prose", - "text": "Easing curves for animations and transitions." + "text": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character." }, { "type": "table", @@ -36,6 +36,14 @@ export const docs = { [ "--ease-standard", "cubic-bezier(0.24, 1, 0.4, 1)" + ], + [ + "--ease-entrance", + "cubic-bezier(0.24, 1, 0.4, 1)" + ], + [ + "--ease-move", + "cubic-bezier(0.24, 1, 0.4, 1)" ] ] } diff --git a/packages/cli/assets/docs/tree/tokens.doc.mjs b/packages/cli/assets/docs/tree/tokens.doc.mjs index 0349b6eb7fc67..2fda462658e71 100644 --- a/packages/cli/assets/docs/tree/tokens.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens.doc.mjs @@ -2,7 +2,7 @@ // AUTO-GENERATED — do not edit manually. // Run: node scripts/generate-token-docs.mjs -// Total: 282 tokens across 15 categories. +// Total: 284 tokens across 15 categories. /** @type {import('@astryxdesign/cli/authoring').NamespaceDoc} */ export const docs = { diff --git a/packages/cli/assets/theme.template.ts b/packages/cli/assets/theme.template.ts index dfa1aeaa7628b..aa80d7492df89 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -181,12 +181,27 @@ export const myTheme = defineTheme({ radius: {base: 4, multiplier: 1}, /** - * Duration scale in ms. Each of fast/medium/slow also gets a -min and -max - * sibling computed with `ratio` (min = base × ratio, max = base ÷ ratio), so - * a component can pick a longer or shorter variant of the same tempo. + * Motion. Three knobs set how its CSS feels (touch springs don't read them + * yet): + * tempo — 1 is Astryx's own; 0.75 the quickest, 1.5 the slowest. + * character — 'crisp' | 'standard' | 'expressive': the entrance and move + * curves. Exits and fades never overshoot. + * reducedMotion — 'always' reduces motion for everyone (so far the + * theme's motion-keyed adaptation rules and the press release fade, not + * yet components' other Reduce Motion styles); 'system' (the default) + * follows each person's setting. + * e.g. motion: {tempo: 0.85, character: 'crisp'} + * Finger tracking, momentum, the keyboard, hover, the press's onset delay + * and tap flash, and busy loops never follow them; the press release fade + * follows tempo, up to the 200 ms Reduce Motion cap. + * + * A duration scale in ms wins over tempo for the durations it sets. Each of + * fast/medium/slow also gets a -min and -max sibling computed with `ratio` + * (min = base × ratio, max = base ÷ ratio), so a component can pick a longer + * or shorter variant of the same tempo. * Snappy {fast: 100, medium: 250} · Default {fast: 175, medium: 410, slow: 975} * Cinematic {fast: 200, medium: 500, slow: 1200} - * `easing` overrides --ease-standard. + * `easing` overrides --ease-standard. Explicit `tokens` win over both. * * Reference: `astryx docs motion`. */ diff --git a/packages/cli/foundation/config/theme-private-vars.mjs b/packages/cli/foundation/config/theme-private-vars.mjs index c4aff1824a7c9..ee38d4242ad8f 100644 --- a/packages/cli/foundation/config/theme-private-vars.mjs +++ b/packages/cli/foundation/config/theme-private-vars.mjs @@ -6,18 +6,35 @@ * this check. */ -/** @param {Record} themeDef */ +/** + * The authored adaptation rule values: a defined theme's normalized rules keep + * them, while its resolved `__adaptationRules` hold compiler output. + * @param {Record} themeDef + */ function adaptationRuleValues(themeDef) { - if (Array.isArray(themeDef.__adaptationRules)) { - return themeDef.__adaptationRules; - } const rules = themeDef.adaptations?.rules ?? themeDef.__adaptations?.rules; - if (!Array.isArray(rules)) return []; + if (!Array.isArray(rules)) { + return Array.isArray(themeDef.__adaptationRules) + ? themeDef.__adaptationRules + : []; + } return rules .map(/** @param {any} rule */ rule => rule?.value) .filter(Boolean); } +/** + * The tokens a surface's author wrote. A defined theme's `tokens` are + * resolved, with the private vars the compiler derives (the motion knobs' + * `--_astryx-*`, `spec:AST-108` FR4), so its authored ones are + * `__inputTokens`; a built module is compiler output, checked when built. + * @param {Record} value + */ +function authoredTokens(value) { + if ('__built' in value) return undefined; + return '__inputTokens' in value ? value.__inputTokens : value.tokens; +} + /** * Every named theme-input surface that can contain token or component writes. * @param {Record} themeDef @@ -54,7 +71,10 @@ export function validatePrivateVars(themeDef) { for (const surface of inputSurfaces(themeDef)) { for (const field of ['tokens', 'localTokens']) { - const values = surface.value[field]; + const values = + field === 'tokens' + ? authoredTokens(surface.value) + : surface.value[field]; if (!values || typeof values !== 'object' || Array.isArray(values)) { continue; } diff --git a/packages/cli/foundation/config/theme-private-vars.test.mjs b/packages/cli/foundation/config/theme-private-vars.test.mjs index 76246fcb9907c..830562b6392db 100644 --- a/packages/cli/foundation/config/theme-private-vars.test.mjs +++ b/packages/cli/foundation/config/theme-private-vars.test.mjs @@ -17,6 +17,27 @@ describe('validatePrivateVars', () => { ).toEqual([]); }); + it('checks a defined theme’s authored input, not the private vars its motion knobs derive (spec:AST-108 FR4)', async () => { + const {defineTheme} = await import('@astryxdesign/core/theme'); + const derived = defineTheme({ + name: 'motion-knobs', + motion: {fast: 125, medium: 300, slow: 700, ratio: 0.75, tempo: 1.2}, + adaptations: { + rules: [{when: {pointer: 'coarse'}, value: {motion: {fast: 100}}}], + }, + }); + expect( + Object.keys(derived.tokens).some(name => name.startsWith('--_')), + ).toBe(true); + expect(validatePrivateVars(derived)).toEqual([]); + const authored = defineTheme({ + name: 'authored', + motion: {tempo: 1.2}, + tokens: {'--_astryx-motion-tempo': '3'}, + }); + expect(validatePrivateVars(authored)).toHaveLength(1); + }); + it('reports direct private keys on every authored input surface', () => { const errors = validatePrivateVars({ name: 'private-inputs', diff --git a/packages/cli/test/__golden__/no-icon-set/docs-topics.json b/packages/cli/test/__golden__/no-icon-set/docs-topics.json index 4acf4cfaf27d0..2cc610c83b066 100644 --- a/packages/cli/test/__golden__/no-icon-set/docs-topics.json +++ b/packages/cli/test/__golden__/no-icon-set/docs-topics.json @@ -199,9 +199,10 @@ "movement-principles", "respecting-user-preferences", "usage", + "theme-motion", "best-practices" ], - "skeleton": "e648d618f9141bbd7f15bf970ba9e0d41b59ff9ae89e0cbbb2042e98bd5f0109", + "skeleton": "d6f7008196a026aedfbbe239f8ec90c641c39dddf79da849d7abc6c898dc11fc", "mentions": [] }, "principles": { diff --git a/packages/cli/test/__golden__/no-icon-set/docs-tree.json b/packages/cli/test/__golden__/no-icon-set/docs-tree.json index 718f0f660fc4d..a717afb7be145 100644 --- a/packages/cli/test/__golden__/no-icon-set/docs-tree.json +++ b/packages/cli/test/__golden__/no-icon-set/docs-tree.json @@ -1080,6 +1080,7 @@ "movement-principles", "respecting-user-preferences", "usage", + "theme-motion", "best-practices" ], "mentions": [] diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json index b184f0ba67aac..e81065eb2bd86 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json @@ -3,7 +3,7 @@ "type": "search", "data": { "query": "icon set", - "matchCount": 369, + "matchCount": 370, "results": [ { "domain": "doc", diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt index 72beb71e54f56..fb62b9289644f 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt @@ -1,4 +1,4 @@ -Results for "icon set" (20 of 369) +Results for "icon set" (20 of 370) name: authoring/building-blocks/templates/build-the-template/template-assets/template-icons section: use-astryx-icon domain: doc diff --git a/packages/core/src/Skeleton/Skeleton.tsx b/packages/core/src/Skeleton/Skeleton.tsx index 705331604fbfc..5c7edcd76d02b 100644 --- a/packages/core/src/Skeleton/Skeleton.tsx +++ b/packages/core/src/Skeleton/Skeleton.tsx @@ -97,7 +97,9 @@ const styles = stylex.create({ }, animate: { animationDirection: 'alternate', - animationDuration: durationVars['--duration-medium-max'], + // The loop reads the duration before a theme's tempo scales it + // (spec:AST-108 FR4). + animationDuration: `var(--_astryx-pre-tempo-duration-medium-max, ${durationVars['--duration-medium-max']})`, animationIterationCount: 'infinite', // Disable the pulse under reduced-motion; the static placeholder still // reads as loading (complex-20). diff --git a/packages/core/src/Spinner/Spinner.tsx b/packages/core/src/Spinner/Spinner.tsx index 7f7deddc90b6c..d60cd6fbf4238 100644 --- a/packages/core/src/Spinner/Spinner.tsx +++ b/packages/core/src/Spinner/Spinner.tsx @@ -334,7 +334,9 @@ const styles = stylex.create({ // it (a frozen spinner reads as broken), matching ProgressBar's approach. // The role="status" + "Loading" label still convey busy state (obs-6). animationDuration: { - default: durationVars['--duration-slow-min'], + // The loop reads the duration before a theme's tempo scales it + // (spec:AST-108 FR4). + default: `var(--_astryx-pre-tempo-duration-slow-min, ${durationVars['--duration-slow-min']})`, '@media (prefers-reduced-motion: reduce)': '3s', }, animationIterationCount: 'infinite', diff --git a/packages/core/src/__tests__/pressState.ts b/packages/core/src/__tests__/pressState.ts index 352f450af397d..4e5ee993cd3df 100644 --- a/packages/core/src/__tests__/pressState.ts +++ b/packages/core/src/__tests__/pressState.ts @@ -177,9 +177,11 @@ export function declaresPressPaint(el: Element): boolean { ); } -/** `.2s`, `200ms`, `0.2s` → 200. */ +/** `.2s`, `200ms`, `0.2s` → 200; a scaled clock reads its base, at tempo 1. */ export function durationToMs(value: string): number { - const match = value.trim().match(/^([\d.]+)(ms|s)$/); + // The press release scales its base clock by the theme's tempo, capped under + // Reduce Motion: `calc( * …)`, `min(, …)` (`spec:AST-108` FR4). + const match = value.trim().match(/^(?:(?:calc|min)\(\s*)?([\d.]+)(ms|s)\b/); if (match == null) { return Number.NaN; } @@ -203,10 +205,12 @@ export function hasReleaseFade(el: Element): boolean { const onArmAnimates = rulesWithSelector(el, '[data-astryx-press="on"]').some( rule => rule.includes('animation'), ); + // One duration and its Reduce Motion cap: both the machine's clock. return ( named && - durations.length === 1 && - durations[0] === PRESS_FADE_MS && + durations.length >= 1 && + durations.length <= 2 && + durations.every(duration => duration === PRESS_FADE_MS) && !onArmAnimates ); } diff --git a/packages/core/src/theme/Theme.doc.mjs b/packages/core/src/theme/Theme.doc.mjs index 6c39470e5e31f..936a579b57076 100644 --- a/packages/core/src/theme/Theme.doc.mjs +++ b/packages/core/src/theme/Theme.doc.mjs @@ -69,7 +69,7 @@ export const docs = { type: 'DefinedTheme', required: true, description: - 'Theme object to apply. Prefer built theme objects for static production themes; use runtime `defineTheme()` for dynamic themes.', + "Theme object to apply. Prefer built theme objects for static production themes; use runtime `defineTheme()` for dynamic themes. Its `motion` can set `character` (`'crisp' | 'standard' | 'expressive'`) and `reducedMotion` (`'system' | 'always'`).", }, { name: 'mode', diff --git a/packages/core/src/theme/Theme.tsx b/packages/core/src/theme/Theme.tsx index 4038421c37b5c..ac7f0d7201fa8 100644 --- a/packages/core/src/theme/Theme.tsx +++ b/packages/core/src/theme/Theme.tsx @@ -45,6 +45,7 @@ import {registerTheme} from './themeRegistry'; import {dataAttr} from '../naming'; import {ThemeContext} from './useTheme'; import {warnOnce} from '../utils/devWarning'; +import {REDUCED_MOTION_PROPERTY, resolveMotionKnobs} from './themeMotion'; /** * Theme provider props @@ -67,6 +68,11 @@ const wrapperStyles = stylex.create({ color: colorVars['--color-text-primary'], fontFamily: typographyVars['--font-family-body'], }, + // The reduced-motion mark's custom property (REDUCED_MOTION_PROPERTY in + // themeMotion.ts), which every element under the mark inherits (FR6). + reducedMotion: { + '--_astryx-reduced-motion': '1', + }, light: { colorScheme: 'light', }, @@ -193,11 +199,16 @@ function useThemeStyleInjection(theme: DefinedTheme): void { * * - 'light' | 'dark' → sets data-theme="light" | "dark" * - 'system' → removes data-theme (reset.css defaults to color-scheme: light dark) + * + * A theme with `reducedMotion: 'always'` also marks `` with + * `data-astryx-reduced-motion` and its custom property, so layers portaled + * outside the wrapper reduce their motion too (spec:AST-108 FR6). */ function useRootThemeSync( isNested: boolean, mode: ThemeMode, themeName: string, + reducesMotion: boolean, ): void { useIsomorphicLayoutEffect(() => { if (isNested) { @@ -216,12 +227,22 @@ function useRootThemeSync( // Sync theme name so @scope rules reach portals/fallback viewports. document.documentElement.setAttribute(dataAttr('theme'), themeName); + // The mark and its custom property, which portaled layers inherit. + if (reducesMotion) { + document.documentElement.setAttribute(dataAttr('reduced-motion'), ''); + document.documentElement.style.setProperty(REDUCED_MOTION_PROPERTY, '1'); + } else { + document.documentElement.removeAttribute(dataAttr('reduced-motion')); + document.documentElement.style.removeProperty(REDUCED_MOTION_PROPERTY); + } return () => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute(dataAttr('theme')); + document.documentElement.removeAttribute(dataAttr('reduced-motion')); + document.documentElement.style.removeProperty(REDUCED_MOTION_PROPERTY); }; - }, [isNested, mode, themeName]); + }, [isNested, mode, themeName, reducesMotion]); } // ============================================================================= @@ -251,7 +272,13 @@ export function Theme({ registerTheme(theme); useThemeStyleInjection(theme); - useRootThemeSync(isNested, mode, theme.name); + // spec:AST-108 FR6: 'always' marks the theme's root. So far motion-keyed + // adaptation rules and the press release fade key reduced motion on the + // mark; components' other Reduce Motion styles don't yet (a known gap in + // `architecture:theme-authoring-contract`). + const knobs = resolveMotionKnobs(theme.__axes?.motion); + const reducesMotion = knobs.reducedMotion === 'always'; + useRootThemeSync(isNested, mode, theme.name, reducesMotion); // Get color-scheme style const colorSchemeStyle = @@ -268,8 +295,13 @@ export function Theme({
{children}
diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index adf01d996d5e3..008f060b89c5e 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -117,7 +117,7 @@ import { fontWeightDefaults, typeScaleDefaults, } from './tokens.stylex'; -import type {MotionScaleConfig} from './expandMotionScale'; +import type {ThemeMotionConfig} from './themeMotion'; import type {RadiusScaleConfig} from './expandRadiusScale'; import type {ColorScaleConfig} from './expandColorScale'; import {resolveThemeValues, type ThemeValuesInput} from './resolveThemeValues'; @@ -290,13 +290,29 @@ export interface DefineThemeInput< */ typography?: TypographyConfig; /** - * Motion configuration. Computes duration min/max variants from - * base values and a scaling ratio: min = base × ratio, max = base / ratio. - * - * Explicit `tokens` overrides take precedence over motion-generated values. + * Motion configuration: three knobs (spec:AST-108), a motion scale, or both. + * + * - `tempo` (default 1, clamped to 0.75–1.5) scales every motion duration + * token; touch motion's springs don't read it yet. + * - `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the + * entrance and move curves; exits and fades never overshoot. + * - `reducedMotion` (`'system'` or `'always'`): `'always'` reduces motion + * for everyone; so far the theme's motion-keyed adaptation rules and the + * press release fade follow it, not yet components' other Reduce Motion + * styles. Nothing loosens a person's own setting. + * + * A motion scale computes duration min/max variants from base values and a + * ratio (min = base × ratio, max = base / ratio); its durations win over + * `tempo`, and explicit `tokens` overrides win over both. Finger tracking, + * momentum, the keyboard, hover, the press's onset delay and tap flash, and + * busy loops never follow the knobs; the press release fade follows + * `tempo`. * * @example * ``` + * motion: { tempo: 1.2, character: 'crisp' } + * + * // A motion scale, as before: * motion: { fast: 175, medium: 410, slow: 975, ratio: 0.75 } * * // Suggested starting points: @@ -305,7 +321,7 @@ export interface DefineThemeInput< * // Cinematic: { fast: 200, medium: 500, slow: 1200, ratio: 0.7 } * ``` */ - motion?: MotionScaleConfig; + motion?: ThemeMotionConfig; /** * Radius configuration. Generates radius token overrides * from a base unit and multiplier. @@ -648,6 +664,43 @@ function describeBadBase(value: unknown): string { return `an object with keys [${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}]`; } +/** The slow band's duration tokens. */ +const SLOW_BAND = [ + '--duration-slow-min', + '--duration-slow', + '--duration-slow-max', +] as const; + +/** + * The base's tokens for the duration-scale fields a child leaves unset + * (spec:AST-108 FR5, field by field): a child that sets no `slow` of its own + * regenerates only the fast and medium bands, so an inherited slow band stays + * as the base's tokens hold it, at the ratio it was made with; and a child + * that sets no `easing` keeps the base's `--ease-standard`, an explicit one + * included, however many themes down either is inherited. + */ +function inheritedScaleTokens( + base: DefinedTheme, + baseMotion: ThemeMotionConfig, + own: ThemeMotionConfig, +): Partial> | undefined { + const names = [ + ...('slow' in baseMotion && !('slow' in own) ? SLOW_BAND : []), + ...('easing' in baseMotion && !('easing' in own) + ? (['--ease-standard'] as const) + : []), + ]; + if (names.length === 0) { + return undefined; + } + const tokens = base.tokens as Record; + return Object.fromEntries( + names.flatMap(name => + tokens[name] !== undefined ? [[name, tokens[name]]] : [], + ), + ); +} + /** * Create an Astryx theme. * @@ -682,13 +735,33 @@ export function defineTheme< // The theme's own value axes and the resolved base an `extends` supplies. // The same axis metadata is retained so adaptation rules can complete partial // configs without approximating values from unrelated built-in defaults. + // + // Motion inherits field by field (spec:AST-108 FR5): a duration-scale field + // or knob the child leaves unset keeps its base's value, so the child's + // motion tokens come from the merged configuration. They resolve base first, + // then generated (`architecture:theme-authoring-contract`): what the child + // generates replaces the base's tokens, its explicit ones included. A + // child's motion regenerates only what it sets: an inherited slow band and + // an inherited easing keep the base's tokens. + const baseMotion = base?.__axes?.motion; + const motion: ThemeMotionConfig | undefined = + input.motion != null && baseMotion != null + ? {...baseMotion, ...input.motion} + : input.motion; + const inheritedScale = + input.motion != null && base != null && baseMotion != null + ? inheritedScaleTokens(base, baseMotion, input.motion) + : undefined; const ownValues: ThemeValuesInput = { typography: input.typography, color: input.color, radius: input.radius, - motion: input.motion, + motion, syntax: input.syntax, - tokens: input.tokens, + tokens: + inheritedScale != null && Object.keys(inheritedScale).length > 0 + ? {...inheritedScale, ...input.tokens} + : input.tokens, components: input.components, }; const ownAxes: ThemeGenerativeAxes = { @@ -701,7 +774,11 @@ export function defineTheme< ? {tokens: base.tokens, components: base.components} : undefined; - const {tokens, components} = resolveThemeValues(ownValues, seed); + // An explicit --ease-standard follows the character the theme inherits + // when it sets no motion of its own (FR2). + const {tokens, components} = resolveThemeValues(ownValues, seed, { + inheritedCharacter: baseMotion?.character, + }); // On-media token overrides (base's resolved surface, then defaults, then // this theme's own overrides). They compile after adaptations so the diff --git a/packages/core/src/theme/generateThemeRules.ts b/packages/core/src/theme/generateThemeRules.ts index 9d5e7cb543967..462d11eaeb486 100644 --- a/packages/core/src/theme/generateThemeRules.ts +++ b/packages/core/src/theme/generateThemeRules.ts @@ -1751,7 +1751,7 @@ export function generateOnMediaCSS( } if (roleParts.length > 0) { adaptationMediaRoleBlocks.push( - generateAdaptationMediaBlock(rule.query, roleParts, scopeSelector), + adaptationBlocks(rule, roleParts, scopeSelector), ); } } @@ -1820,6 +1820,40 @@ function generateAdaptationMediaBlock( .join('\n\n')}\n }\n}`; } +/** The mark of `reducedMotion: 'always'` on a theme's root (spec:AST-108 FR6). */ +const REDUCED_MOTION_MARK = `[data-${dataAttrNamespace}-reduced-motion]`; + +/** + * An adaptation rule's blocks. A rule keyed on motion also honours the + * reduced-motion mark, on the theme's root or an ancestor's (spec:AST-108 + * FR6): a `'reduce'` rule gets a second block without the motion feature, + * scoped to a marked theme, so it applies whatever the person's setting, and + * a `'no-preference'` rule's scope leaves marked themes out. + */ +function adaptationBlocks( + rule: Pick, + parts: string[], + scopeSelector: string, +): string { + if (rule.when.motion === 'no-preference') { + return generateAdaptationMediaBlock( + rule.query, + parts, + `${scopeSelector}:not(${REDUCED_MOTION_MARK}):not(${REDUCED_MOTION_MARK} *)`, + ); + } + const block = generateAdaptationMediaBlock(rule.query, parts, scopeSelector); + if (rule.when.motion !== 'reduce' || rule.markQuery === undefined) { + return block; + } + const markedScope = `${scopeSelector}${REDUCED_MOTION_MARK}, ${REDUCED_MOTION_MARK} ${scopeSelector}`; + const marked = + rule.markQuery === '' + ? `@scope (${markedScope}) to (${THEME_SCOPE_TO}) {\n${parts.join('\n\n')}\n}` + : generateAdaptationMediaBlock(rule.markQuery, parts, markedScope); + return `${block}\n\n${marked}`; +} + /** * Generate CSS for a theme's ordered adaptation rules. * @@ -1866,7 +1900,7 @@ export function generateAdaptationCSS( if (parts.length === 0) { continue; } - blocks.push(generateAdaptationMediaBlock(rule.query, parts, scopeSelector)); + blocks.push(adaptationBlocks(rule, parts, scopeSelector)); } // Replay explicit role writes after every generated source alias. This keeps @@ -1885,9 +1919,7 @@ export function generateAdaptationCSS( roleEntries, ); if (roleRule !== null) { - blocks.push( - generateAdaptationMediaBlock(rule.query, [roleRule], scopeSelector), - ); + blocks.push(adaptationBlocks(rule, [roleRule], scopeSelector)); } } @@ -1916,13 +1948,7 @@ export function generateAdaptationCSS( {authoredOnly: true}, ); if (authoredWeightParts.length > 0) { - blocks.push( - generateAdaptationMediaBlock( - rule.query, - authoredWeightParts, - scopeSelector, - ), - ); + blocks.push(adaptationBlocks(rule, authoredWeightParts, scopeSelector)); } } } diff --git a/packages/core/src/theme/motionKnobs.ts b/packages/core/src/theme/motionKnobs.ts new file mode 100644 index 0000000000000..cfd9b7c85d9be --- /dev/null +++ b/packages/core/src/theme/motionKnobs.ts @@ -0,0 +1,85 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file motionKnobs.ts + * @input A theme's motion knobs (`tempo`, `character`, `reducedMotion`) + * @output Their types and `resolveMotionKnobs`, which fills in the defaults + * and clamps `tempo` (`spec:AST-108` FR1) + * @position Theme utility with no imports: themeMotion.ts builds the motion + * tokens on it, and the motion foundation reads a theme's knobs through it + * without loading the token stylesheet. Pure; safe on the server and in + * plain Node. + * + * SYNC: When modified, update: + * - /packages/core/src/theme/themeMotion.ts (re-exports these) + * - /packages/core/src/theme/themeMotion.test.ts + */ + +/** How lively a theme's motion feels. */ +export type MotionCharacter = 'crisp' | 'standard' | 'expressive'; + +/** Whether a theme reduces motion for everyone. */ +export type ThemeReducedMotion = 'system' | 'always'; + +/** The three motion knobs a theme sets (`spec:AST-108` FR1). */ +export interface ThemeMotionKnobs { + /** + * Scales every motion duration: 1 is Astryx's own, 0.75 the quickest and + * 1.5 the slowest (values outside are clamped). Default 1. + */ + tempo?: number; + /** + * `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, + * `'expressive'` overshoots slightly on entrances and moves. Exits and + * fades never overshoot. Default `'standard'`. + */ + character?: MotionCharacter; + /** + * `'always'` reduces motion for everyone; `'system'` follows each person's + * Reduce Motion setting. No value loosens Reduce Motion. Default `'system'`. + */ + reducedMotion?: ThemeReducedMotion; +} + +/** The knobs with their defaults filled in and `tempo` clamped. */ +export interface ResolvedMotionKnobs { + readonly tempo: number; + readonly character: MotionCharacter; + readonly reducedMotion: ThemeReducedMotion; + /** + * Whether the theme sets `tempo` itself. A `MotionScaleConfig` alone + * implies a tempo for its CSS durations only; the animator's springs stay + * at tempo 1 unless this is true (FR5). + */ + readonly tempoIsSet: boolean; +} + +/** The range `tempo` is clamped to. */ +export const TEMPO_RANGE = {min: 0.75, max: 1.5} as const; + +const CHARACTERS: ReadonlyArray = [ + 'crisp', + 'standard', + 'expressive', +]; + +/** The knobs of `config`, defaults filled in, `tempo` clamped. */ +export function resolveKnobs( + config: ThemeMotionKnobs | undefined, +): ResolvedMotionKnobs { + const tempoIsSet = + typeof config?.tempo === 'number' && Number.isFinite(config.tempo); + const tempo = tempoIsSet + ? Math.min( + TEMPO_RANGE.max, + Math.max(TEMPO_RANGE.min, config.tempo as number), + ) + : 1; + const character = + config?.character != null && CHARACTERS.includes(config.character) + ? config.character + : 'standard'; + const reducedMotion = + config?.reducedMotion === 'always' ? 'always' : 'system'; + return {tempo, character, reducedMotion, tempoIsSet}; +} diff --git a/packages/core/src/theme/resolveThemeValues.ts b/packages/core/src/theme/resolveThemeValues.ts index acf306994a22d..6185b9eb2a285 100644 --- a/packages/core/src/theme/resolveThemeValues.ts +++ b/packages/core/src/theme/resolveThemeValues.ts @@ -31,8 +31,14 @@ import { generateTypeScaleComponents, type TypeScaleConfig, } from './expandTypeScale'; -import {expandMotionScale, type MotionScaleConfig} from './expandMotionScale'; +import { + expandThemeMotion, + preTempoName, + type MotionCharacter, + type ThemeMotionConfig, +} from './themeMotion'; import {expandRadiusScale, type RadiusScaleConfig} from './expandRadiusScale'; +import {easeDefaults} from './tokens.stylex'; import {expandColorScale, type ColorScaleConfig} from './expandColorScale'; import {deepMergeComponents} from './mergeComponents'; @@ -54,8 +60,8 @@ export interface ThemeValuesInput { color?: ColorScaleConfig; /** Radius scale configuration. */ radius?: RadiusScaleConfig; - /** Motion scale configuration. */ - motion?: MotionScaleConfig; + /** Motion: the three knobs, a motion scale configuration, or both. */ + motion?: ThemeMotionConfig; /** Syntax highlighting theme. */ syntax?: SyntaxThemeDefinition; /** Explicit token overrides — highest precedence. */ @@ -271,7 +277,20 @@ export function buildFontFamilyTokens( export function resolveThemeValues( input: ThemeValuesInput, seed?: ThemeValuesSeed, - {strictTokens = false}: {strictTokens?: boolean} = {}, + { + strictTokens = false, + inheritedCharacter, + standardBeneath, + }: { + strictTokens?: boolean; + /** + * The motion character in effect beneath this layer, from a base theme or + * the theme an adaptation rule belongs to, for a layer that sets none. + */ + inheritedCharacter?: MotionCharacter; + /** The --ease-standard beneath a layer resolved without a seed: a rule's theme's. */ + standardBeneath?: string; + } = {}, ): ResolvedThemeValues { const tokens: Record = {...seed?.tokens}; @@ -293,9 +312,19 @@ export function resolveThemeValues( Object.assign(tokens, expandRadiusScale(input.radius)); } - // 4. Motion-generated tokens + // 4. Motion-generated tokens (spec:AST-108 FR2, FR5). A layer that writes + // durations also writes the copies busy loops read (FR4), so an enclosing + // theme's tempo copies never stand in for its own: without a tempo of its + // own, they are its durations. if (input.motion) { - Object.assign(tokens, expandMotionScale(input.motion)); + const motion = expandThemeMotion(input.motion); + Object.assign(tokens, motion); + for (const [name, value] of Object.entries(motion)) { + const preTempo = preTempoName(name); + if (preTempo !== name && !(preTempo in motion)) { + tokens[preTempo] = value; + } + } } // 5. Font family tokens @@ -314,9 +343,42 @@ export function resolveThemeValues( // 7. Explicit token overrides — highest precedence within this input layer. // A generated axis in the same root or adaptation value cannot beat them. if (input.tokens) { + const own = input.tokens as Partial>; for (const [key, value] of Object.entries(input.tokens)) { if (value !== undefined) { tokens[key] = resolveTokenValue(value, {strict: strictTokens}); + // An explicit duration beats tempo, so a busy loop reading the + // pre-tempo value reads it too (spec:AST-108 FR4). + const preTempo = preTempoName(key); + if (preTempo !== key && own[preTempo] === undefined) { + tokens[preTempo] = tokens[key]; + } + } + } + } + + // At 'standard', --ease-entrance and --ease-move equal the resolved + // --ease-standard (spec:AST-108 FR2): the layer's own, else the one beneath + // it. A layer that sets them itself, or whose character, its own or the one + // it inherits, picks its own curve, keeps them. + const own: Partial> = input.tokens ?? {}; + const ownCharacter = + input.motion != null && 'character' in input.motion + ? input.motion.character + : undefined; + const character = ownCharacter ?? inheritedCharacter; + if ( + ownCharacter === 'standard' || + (own['--ease-standard'] !== undefined && + (character === undefined || character === 'standard')) + ) { + const standard = + tokens['--ease-standard'] ?? + standardBeneath ?? + easeDefaults['--ease-standard']; + for (const name of ['--ease-entrance', '--ease-move']) { + if (own[name] === undefined) { + tokens[name] = standard; } } } diff --git a/packages/core/src/theme/themeAdaptations.test.ts b/packages/core/src/theme/themeAdaptations.test.ts index 1f9de2df3ab28..fddad76366dcf 100644 --- a/packages/core/src/theme/themeAdaptations.test.ts +++ b/packages/core/src/theme/themeAdaptations.test.ts @@ -185,9 +185,89 @@ describe('closed conditions and media queries', () => { expect(mediaPreludes(generateAdaptationCSS(theme).component)).toEqual([ '(width >= 768px) and (width < 1280px) and (pointer: coarse) and (prefers-contrast: more) and (prefers-reduced-motion: reduce)', + // The same rule under the reduced-motion mark (spec:AST-108 FR6). + '(width >= 768px) and (width < 1280px) and (pointer: coarse) and (prefers-contrast: more)', ]); }); + describe('a rule keyed on motion under the reduced-motion mark (spec:AST-108 FR6)', () => { + const MARK = '[data-astryx-reduced-motion]'; + const scopes = (css: string) => + [...css.matchAll(/@scope \(([^)]*(?:\([^)]*\)[^)]*)*)\) to/g)].map( + match => match[1], + ); + + it("a 'reduce' rule also applies inside a marked theme, whatever the person's setting", () => { + const css = generateAdaptationCSS( + defineTheme( + adaptationInput([ + { + when: {motion: 'reduce'}, + value: {tokens: {'--duration-fast': '0ms'}}, + }, + ]), + ), + ).component; + expect(mediaPreludes(css)).toEqual(['(prefers-reduced-motion: reduce)']); + expect(scopes(css)).toEqual([ + '[data-astryx-theme="adaptive"]', + `[data-astryx-theme="adaptive"]${MARK}, ${MARK} [data-astryx-theme="adaptive"]`, + ]); + expect(count(css, '--duration-fast: 0ms')).toBe(2); + }); + + it("keeps a 'reduce' rule's other conditions under the mark", () => { + const css = generateAdaptationCSS( + defineTheme( + adaptationInput([ + { + when: {motion: 'reduce', pointer: 'coarse'}, + value: {tokens: {'--duration-fast': '0ms'}}, + }, + ]), + ), + ).component; + expect(mediaPreludes(css)).toEqual([ + '(pointer: coarse) and (prefers-reduced-motion: reduce)', + '(pointer: coarse)', + ]); + }); + + it("leaves a marked theme out of a 'no-preference' rule", () => { + const css = generateAdaptationCSS( + defineTheme( + adaptationInput([ + { + when: {motion: 'no-preference'}, + value: {tokens: {'--duration-fast': '300ms'}}, + }, + ]), + ), + ).component; + expect(mediaPreludes(css)).toEqual([ + '(prefers-reduced-motion: no-preference)', + ]); + expect(scopes(css)).toEqual([ + `[data-astryx-theme="adaptive"]:not(${MARK}):not(${MARK} *)`, + ]); + }); + + it('changes nothing for a rule that is not keyed on motion', () => { + const css = generateAdaptationCSS( + defineTheme( + adaptationInput([ + { + when: {pointer: 'coarse'}, + value: {tokens: {'--duration-fast': '50ms'}}, + }, + ]), + ), + ).component; + expect(scopes(css)).toEqual(['[data-astryx-theme="adaptive"]']); + expect(css).not.toContain(MARK); + }); + }); + it.each([ [{}, /must contain at least one condition/], [{pointer: undefined}, /must contain at least one condition/], diff --git a/packages/core/src/theme/themeAdaptations.ts b/packages/core/src/theme/themeAdaptations.ts index 6abc4384bc89f..528001f0d8965 100644 --- a/packages/core/src/theme/themeAdaptations.ts +++ b/packages/core/src/theme/themeAdaptations.ts @@ -27,6 +27,7 @@ import type {ComponentStyleMap, TokenName, TokenValue} from './defineTheme'; import type {TypographyConfig, TypographyRole} from './types'; import type {MotionScaleConfig} from './expandMotionScale'; +import type {ThemeMotionConfig, ThemeMotionKnobs} from './themeMotion'; import type {RadiusScaleConfig} from './expandRadiusScale'; import type {ColorScaleConfig} from './expandColorScale'; import {resolveThemeValues, type ThemeValuesInput} from './resolveThemeValues'; @@ -133,8 +134,11 @@ export interface ThemeAdaptationValue { color?: Partial; /** Radius axis overrides, completed from root metadata. */ radius?: Partial; - /** Motion axis overrides, completed from root metadata. */ - motion?: Partial; + /** + * Motion axis overrides, completed from root metadata: scale fields, or the + * `tempo` and `character` knobs (spec:AST-108 FR5). + */ + motion?: Partial & Omit; /** Portable semantic-token writes. */ tokens?: Partial>; /** Replacements for exact theme-local names enrolled by the root lineage. */ @@ -176,7 +180,7 @@ export interface ThemeGenerativeAxes { typography?: TypographyConfig; color?: ColorScaleConfig; radius?: RadiusScaleConfig; - motion?: MotionScaleConfig; + motion?: ThemeMotionConfig; } /** One rule lowered to concrete CSS writes. */ @@ -185,6 +189,14 @@ export interface ResolvedThemeAdaptationRule { when: ThemeAdaptationCondition; /** CSS media-query prelude without the `@media` keyword. */ query: string; + /** + * For a rule keyed on `motion`: the prelude without the motion feature, + * empty when motion is the rule's only condition. Under the reduced-motion + * mark of `reducedMotion: 'always'` (on the theme's root or an ancestor's) + * a `'reduce'` rule applies whatever the person's setting, and a + * `'no-preference'` rule never does (`spec:AST-108` FR6). + */ + markQuery?: string; /** Concrete portable token writes produced by this rule. */ tokens: Record; /** Concrete theme-local token writes produced by this rule. */ @@ -570,13 +582,40 @@ function completeRadius( } function completeMotion( - root: MotionScaleConfig | undefined, - rule: Partial, + root: ThemeMotionConfig | undefined, + rule: Partial & ThemeMotionKnobs, path: string, -): MotionScaleConfig { - const fast = rule.fast ?? root?.fast; - const medium = rule.medium ?? root?.medium; - const ratio = rule.ratio ?? root?.ratio; +): ThemeMotionConfig { + if (rule.reducedMotion !== undefined) { + throw new Error( + `${path}.reducedMotion is a theme-wide setting: set it on the theme's own ` + + `motion, not in an adaptation rule.`, + ); + } + const tempo = rule.tempo ?? root?.tempo; + const character = rule.character ?? root?.character; + const knobs: ThemeMotionKnobs = { + ...(tempo !== undefined ? {tempo} : {}), + ...(character !== undefined ? {character} : {}), + }; + if (knobs.tempo !== undefined) { + assertFiniteNumber(knobs.tempo, `${path}.tempo`); + } + const rootScale: Partial = + root != null && 'fast' in root ? root : {}; + const writesScale = + rule.fast !== undefined || + rule.medium !== undefined || + rule.ratio !== undefined || + rule.slow !== undefined || + rule.easing !== undefined; + if (!writesScale && 'fast' in rootScale === false) { + // Knobs only, over a root with no scale: the knobs generate the tokens. + return knobs; + } + const fast = rule.fast ?? rootScale.fast; + const medium = rule.medium ?? rootScale.medium; + const ratio = rule.ratio ?? rootScale.ratio; if (fast === undefined || medium === undefined || ratio === undefined) { throw new Error( `${path} must supply \`fast\`, \`medium\`, and \`ratio\` unless each missing field exists on the effective root motion axis.`, @@ -585,17 +624,18 @@ function completeMotion( assertFiniteNumber(fast, `${path}.fast`); assertFiniteNumber(medium, `${path}.medium`); assertFiniteNumber(ratio, `${path}.ratio`); - const slow = rule.slow ?? root?.slow; + const slow = rule.slow ?? rootScale.slow; if (slow !== undefined) { assertFiniteNumber(slow, `${path}.slow`); } - const easing = rule.easing ?? root?.easing; + const easing = rule.easing ?? rootScale.easing; return { fast, medium, ratio, ...(slow !== undefined ? {slow} : {}), ...(easing !== undefined ? {easing} : {}), + ...knobs, }; } @@ -647,6 +687,7 @@ function mediaQueryForCondition( ruleIndex: number, condition: ThemeAdaptationCondition, points: WidthBreakpoints, + options: {withoutMotion?: boolean} = {}, ): string { const path = `defineTheme("${themeName}").adaptations.rules[${ruleIndex}].when`; const parts: string[] = []; @@ -676,11 +717,14 @@ function mediaQueryForCondition( if (condition.contrast !== undefined) { parts.push(`(prefers-contrast: ${condition.contrast})`); } - if (condition.motion !== undefined) { + if (condition.motion !== undefined && options.withoutMotion !== true) { parts.push(`(prefers-reduced-motion: ${condition.motion})`); } if (parts.length === 0) { + if (options.withoutMotion === true) { + return ''; + } throw new Error(`${path} must contain at least one concrete condition.`); } return parts.join(' and '); @@ -990,7 +1034,14 @@ export function resolveThemeAdaptationRules( // acceptance to preserve, so a malformed token is rejected here rather // than emitted. The root path stays lenient for compatibility. // SYNC: packages/core/src/theme/resolveThemeValues.ts (resolveTokenValue) - {strictTokens: true}, + // A rule without motion of its own keeps the theme's character, so its + // explicit --ease-standard leaves that character's curves alone; one + // at 'standard' matches the theme's --ease-standard. + { + strictTokens: true, + inheritedCharacter: axes.motion?.character, + standardBeneath: rootTokens['--ease-standard'], + }, ); for (const [name, value] of Object.entries(resolved.tokens)) { assertConcreteLeaf( @@ -1031,6 +1082,17 @@ export function resolveThemeAdaptationRules( rule.when, adaptations.widthBreakpoints, ), + ...(rule.when.motion !== undefined + ? { + markQuery: mediaQueryForCondition( + themeName, + index, + rule.when, + adaptations.widthBreakpoints, + {withoutMotion: true}, + ), + } + : {}), tokens: resolved.tokens, localTokens, components: input.typography?.scale diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts new file mode 100644 index 0000000000000..3ce563944a660 --- /dev/null +++ b/packages/core/src/theme/themeMotion.test.ts @@ -0,0 +1,578 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeMotion.test.ts + * @input vitest, the theme motion knobs, defineTheme and expandMotionScale + * @output Checks `spec:AST-108` FR1, FR2, FR5 and FR6's resolver: today's + * tokens at the defaults, each tempo bound and the clamp, each character's + * curves with exits unchanged, `reducedMotion`, a `MotionScaleConfig` that + * renders exactly as before and wins over `tempo`, explicit tokens winning + * over both, adaptation rules setting the knobs, and `resolveThemeMotion` + * @position Testing; validates themeMotion.ts and its use in defineTheme + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import {defineTheme} from './defineTheme'; +import {generateAdaptationCSS} from './generateThemeRules'; +import {expandMotionScale} from './expandMotionScale'; +import { + CHARACTER_EASE, + expandThemeMotion, + resolveMotionKnobs, + resolveThemeMotion, +} from './themeMotion'; +import {durationDefaults, easeDefaults} from './tokens.stylex'; + +const TODAY_MS = [130, 175, 230, 310, 410, 550, 730, 975, 1300]; +const STANDARD = 'cubic-bezier(0.24, 1, 0.4, 1)'; + +function durationsMs(tokens: Readonly>): number[] { + return Object.keys(durationDefaults).map(name => + Number.parseFloat(tokens[name]), + ); +} + +describe('the motion knobs (spec:AST-108 FR1)', () => { + it('default to tempo 1, standard and system', () => { + expect(resolveMotionKnobs(undefined)).toEqual({ + tempo: 1, + character: 'standard', + reducedMotion: 'system', + tempoIsSet: false, + }); + }); + + it('clamp tempo to 0.75–1.5 and ignore a value that is no number', () => { + expect(resolveMotionKnobs({tempo: 0.5}).tempo).toBe(0.75); + expect(resolveMotionKnobs({tempo: 2}).tempo).toBe(1.5); + expect(resolveMotionKnobs({tempo: 1.2}).tempo).toBe(1.2); + expect(resolveMotionKnobs({tempo: Number.NaN})).toMatchObject({ + tempo: 1, + tempoIsSet: false, + }); + }); + + it('take reducedMotion always, and system otherwise', () => { + expect(resolveMotionKnobs({reducedMotion: 'always'}).reducedMotion).toBe( + 'always', + ); + expect(resolveMotionKnobs({reducedMotion: 'system'}).reducedMotion).toBe( + 'system', + ); + }); +}); + +describe('the CSS motion tokens (spec:AST-108 FR2)', () => { + it('are today\u2019s at the defaults', () => { + expect(expandThemeMotion({})).toEqual({}); + const explicit = expandThemeMotion({ + tempo: 1, + character: 'standard', + reducedMotion: 'system', + }); + expect(durationsMs(explicit)).toEqual(TODAY_MS); + expect(explicit['--ease-entrance']).toBe(STANDARD); + expect(explicit['--ease-move']).toBe(STANDARD); + const motion = resolveThemeMotion({}); + expect(durationsMs(motion.durations)).toEqual(TODAY_MS); + expect(motion.easings).toEqual({ + '--ease-standard': STANDARD, + '--ease-entrance': STANDARD, + '--ease-move': STANDARD, + }); + expect(easeDefaults['--ease-entrance']).toBe(STANDARD); + expect(easeDefaults['--ease-move']).toBe(STANDARD); + }); + + it.each([0.75, 1.2, 1.5])( + 'scale every duration by tempo %s, to the ms', + tempo => { + const tokens = expandThemeMotion({tempo}); + expect(durationsMs(tokens)).toEqual( + TODAY_MS.map(ms => Math.round(ms * tempo)), + ); + }, + ); + + it('clamp the scale: a tempo of 3 is 1.5', () => { + expect(durationsMs(expandThemeMotion({tempo: 3}))).toEqual( + TODAY_MS.map(ms => Math.round(ms * 1.5)), + ); + }); + + it.each(['crisp', 'expressive'] as const)( + 'set the entrance and move curves at %s, and leave the standard and exit curves alone', + character => { + const tokens = expandThemeMotion({character}); + expect(tokens['--ease-entrance']).toBe(CHARACTER_EASE[character]); + expect(tokens['--ease-move']).toBe(CHARACTER_EASE[character]); + expect(tokens).not.toHaveProperty('--ease-standard'); + expect(tokens).not.toHaveProperty('--ease-exit'); + expect( + Object.keys(tokens).some(name => name.startsWith('--duration')), + ).toBe(false); + }, + ); + + it('keep entrances and moves on a theme\u2019s own standard curve at standard', () => { + const easing = 'cubic-bezier(0.2, 0, 0, 1)'; + const tokens = expandThemeMotion({ + fast: 175, + medium: 410, + ratio: 0.75, + easing, + }); + expect(tokens['--ease-standard']).toBe(easing); + expect(tokens['--ease-entrance']).toBe(easing); + expect(tokens['--ease-move']).toBe(easing); + }); +}); + +describe('existing motion configuration (spec:AST-108 FR5)', () => { + const scale = {fast: 100, medium: 250, ratio: 0.75}; + + it('a MotionScaleConfig generates exactly what it did', () => { + expect(expandThemeMotion(scale)).toEqual(expandMotionScale(scale)); + }); + + it('implies no tempo for the animator', () => { + expect(resolveMotionKnobs(scale).tempoIsSet).toBe(false); + }); + + it('its durations win over tempo; tempo scales the durations it leaves out', () => { + const tokens = expandThemeMotion({...scale, tempo: 1.5}); + expect(tokens).toMatchObject(expandMotionScale(scale)); + // No slow band in the scale: tempo scales today\u2019s. + expect(tokens['--duration-slow']).toBe(`${Math.round(975 * 1.5)}ms`); + }); + + it('explicit tokens win over the knobs', () => { + const theme = defineTheme({ + name: 'Knobbed', + motion: {tempo: 1.5, character: 'crisp'}, + tokens: {'--duration-medium': '300ms', '--ease-entrance': 'linear'}, + }); + expect(theme.tokens['--duration-medium']).toBe('300ms'); + expect(theme.tokens['--ease-entrance']).toBe('linear'); + expect(theme.tokens['--duration-fast']).toBe(`${Math.round(175 * 1.5)}ms`); + expect(theme.tokens['--ease-move']).toBe(CHARACTER_EASE.crisp); + }); + + it('a child theme keeps each knob it leaves out and can set one back to its default', () => { + const base = defineTheme({ + name: 'SlowCrisp', + motion: {tempo: 0.8, character: 'crisp'}, + }); + const quicker = defineTheme({ + name: 'SlowCrispChild', + extends: base, + motion: {tempo: 1.2}, + }); + expect(quicker.tokens['--duration-medium']).toBe( + `${Math.round(410 * 1.2)}ms`, + ); + expect(quicker.tokens['--ease-entrance']).toBe(CHARACTER_EASE.crisp); + const reset = defineTheme({ + name: 'SlowCrispReset', + extends: base, + motion: {tempo: 1, character: 'standard'}, + }); + expect(reset.tokens['--duration-medium']).toBe('410ms'); + expect(reset.tokens['--ease-entrance']).toBe(STANDARD); + }); + + it('a theme with no motion writes no motion token, so it renders as before', () => { + const plain = defineTheme({name: 'Plain'}); + expect( + Object.keys(plain.tokens).filter( + name => name.startsWith('--duration') || name.startsWith('--ease'), + ), + ).toEqual([]); + }); + + it('an adaptation rule sets tempo and character like any motion value', () => { + const theme = defineTheme({ + name: 'Adaptive', + adaptations: { + rules: [{when: {pointer: 'coarse'}, value: {motion: {tempo: 0.8}}}], + }, + }); + const css = JSON.stringify(theme); + expect(css).toContain(`${Math.round(410 * 0.8)}ms`); + }); + + it('an adaptation rule cannot set reducedMotion, a theme-wide setting', () => { + expect(() => + defineTheme({ + name: 'Bad', + adaptations: { + rules: [ + { + when: {pointer: 'coarse'}, + value: { + motion: {reducedMotion: 'always'} as never, + }, + }, + ], + }, + }), + ).toThrow(/reducedMotion is a theme-wide setting/); + }); +}); + +describe('resolveThemeMotion (spec:AST-108 FR6)', () => { + it('returns the knobs, durations and easings with explicit tokens applied', () => { + const motion = resolveThemeMotion({ + motion: {tempo: 1.2, character: 'expressive', reducedMotion: 'always'}, + tokens: {'--duration-fast': '200ms'}, + }); + expect(motion.knobs).toEqual({ + tempo: 1.2, + character: 'expressive', + reducedMotion: 'always', + tempoIsSet: true, + }); + expect(motion.durations['--duration-fast']).toBe('200ms'); + expect(motion.durations['--duration-medium']).toBe( + `${Math.round(410 * 1.2)}ms`, + ); + expect(motion.easings['--ease-entrance']).toBe(CHARACTER_EASE.expressive); + expect(motion.easings['--ease-standard']).toBe(STANDARD); + }); +}); + +describe('a layer\u2019s own durations reach the busy loops under an enclosing tempo (spec:AST-108 FR4)', () => { + it('a theme with a duration scale and no tempo writes the loops\u2019 copies of its durations', () => { + const theme = defineTheme({ + name: 'ScaledNested', + motion: {fast: 100, medium: 250, ratio: 0.75}, + }); + for (const name of ['--duration-fast', '--duration-medium-max']) { + expect( + theme.tokens[ + name.replace('--duration-', '--_astryx-pre-tempo-duration-') + ], + ).toBe(theme.tokens[name]); + } + }); + + it('an explicit duration with no tempo writes its copy too', () => { + const theme = defineTheme({ + name: 'ExplicitNested', + tokens: {'--duration-slow': '800ms'}, + }); + expect(theme.tokens['--_astryx-pre-tempo-duration-slow']).toBe('800ms'); + }); + + it('an adaptation rule\u2019s own durations write their copies', () => { + const css = generateAdaptationCSS( + defineTheme({ + name: 'AdaptiveDurations', + adaptations: { + rules: [ + { + when: {pointer: 'coarse'}, + value: {tokens: {'--duration-medium': '300ms'}}, + }, + ], + }, + }), + ).component; + expect(css).toContain('--_astryx-pre-tempo-duration-medium: 300ms'); + }); +}); + +describe('a child of an installed built theme (spec:AST-108 FR5, INV15)', () => { + it('resolves base first, then generated, as a child of a defined theme does', () => { + const base = defineTheme({ + name: 'BuiltBase', + motion: {tempo: 0.8}, + tokens: {'--duration-medium': '333ms', '--ease-entrance': 'linear'}, + }); + // A built module carries the resolved tokens and the axes, not the input. + const built = {...base, __inputTokens: undefined}; + const child = defineTheme({ + name: 'BuiltChild', + extends: built, + motion: {tempo: 1.2}, + }); + // The child's tempo regenerates every duration over the base's tokens, + // its explicit one included (`architecture:theme-authoring-contract`). + expect(child.tokens['--duration-medium']).toBe( + `${Math.round(410 * 1.2)}ms`, + ); + expect(child.tokens['--duration-fast']).toBe(`${Math.round(175 * 1.2)}ms`); + // It generates no entrance curve without a character, so the base's stays. + expect(child.tokens['--ease-entrance']).toBe('linear'); + }); +}); + +describe('an explicit --ease-standard (spec:AST-108 FR2)', () => { + it('is what --ease-entrance and --ease-move equal at standard, in the tokens and resolveThemeMotion alike', () => { + const curve = 'cubic-bezier(0.3, 0, 0.2, 1)'; + for (const motion of [ + undefined, + {character: 'standard' as const}, + {tempo: 1.2}, + ]) { + const theme = defineTheme({ + name: 'OwnStandard', + motion, + tokens: {'--ease-standard': curve}, + }); + expect(theme.tokens['--ease-entrance'], JSON.stringify(motion)).toBe( + curve, + ); + expect(theme.tokens['--ease-move'], JSON.stringify(motion)).toBe(curve); + expect(resolveThemeMotion(theme).easings['--ease-entrance']).toBe(curve); + } + // A child, and a rule, that set 'standard' match the --ease-standard + // beneath them: the base's, or the theme's. + const base = defineTheme({ + name: 'EaseBase', + tokens: {'--ease-standard': curve}, + }); + const standard = {character: 'standard' as const}; + const child = defineTheme({ + name: 'EaseChild', + extends: base, + motion: standard, + }); + const ruled = defineTheme({ + name: 'EaseRule', + tokens: {'--ease-standard': curve}, + adaptations: { + rules: [{when: {pointer: 'coarse'}, value: {motion: standard}}], + }, + }); + for (const tokens of [child.tokens, ruled.__adaptationRules?.[0]?.tokens]) { + expect(tokens?.['--ease-entrance']).toBe(curve); + expect(tokens?.['--ease-move']).toBe(curve); + } + }); +}); + +describe('resolveThemeMotion reads a defined theme\u2019s motion (spec:AST-108 FR6)', () => { + it('takes the knobs a child inherits from its base', () => { + const base = defineTheme({name: 'KnobBase', motion: {tempo: 1.2}}); + const child = defineTheme({name: 'KnobChild', extends: base}); + expect(resolveThemeMotion(child).knobs.tempo).toBe(1.2); + }); +}); + +describe('the press release follows tempo (spec:AST-108 FR4, component:Button FR15)', () => { + it('writes the tempo beside the duration tokens for the release fade, and nothing without one', () => { + expect(expandThemeMotion({tempo: 1.5})['--_astryx-motion-tempo']).toBe( + '1.5', + ); + expect( + expandThemeMotion({character: 'crisp'})['--_astryx-motion-tempo'], + ).toBeUndefined(); + }); +}); + +describe('busy loops read the duration before tempo (spec:AST-108 FR4)', () => { + it.each([0.75, 1.5])( + 'at tempo %s the pre-tempo durations stay today\u2019s while the tokens scale', + tempo => { + const tokens = expandThemeMotion({tempo}); + expect(tokens['--_astryx-pre-tempo-duration-slow-min']).toBe('730ms'); + expect(tokens['--_astryx-pre-tempo-duration-medium-max']).toBe('550ms'); + expect(tokens['--duration-slow-min']).toBe( + `${Math.round(730 * tempo)}ms`, + ); + }, + ); + + it('a duration scale still times the loops, and an explicit token too', () => { + const scale = {fast: 100, medium: 250, ratio: 0.75}; + const tokens = expandThemeMotion({...scale, tempo: 1.5}); + expect(tokens['--_astryx-pre-tempo-duration-medium-max']).toBe( + expandMotionScale(scale)['--duration-medium-max'], + ); + const theme = defineTheme({ + name: 'LoopTimed', + motion: {tempo: 1.5}, + tokens: {'--duration-medium-max': '600ms'}, + }); + expect(theme.tokens['--_astryx-pre-tempo-duration-medium-max']).toBe( + '600ms', + ); + }); + + it('names every property it writes beside the core tokens privately, with --_astryx- (architecture:theme-tokens INV1)', () => { + const core = new Set([ + ...Object.keys(durationDefaults), + ...Object.keys(easeDefaults), + ]); + const tokens = expandThemeMotion({ + fast: 100, + medium: 250, + ratio: 0.75, + tempo: 1.2, + character: 'crisp', + }); + const extra = Object.keys(tokens).filter(name => !core.has(name)); + expect(extra.length).toBeGreaterThan(0); + for (const name of extra) { + expect(name).toMatch(/^--_astryx-/); + } + }); + + it('Spinner and Skeleton read the pre-tempo duration first', () => { + const read = (file: string) => + fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + expect(read('Spinner/Spinner.tsx')).toContain( + "`var(--_astryx-pre-tempo-duration-slow-min, ${durationVars['--duration-slow-min']})`", + ); + expect(read('Skeleton/Skeleton.tsx')).toContain( + "`var(--_astryx-pre-tempo-duration-medium-max, ${durationVars['--duration-medium-max']})`", + ); + }); +}); + +describe('an extending theme inherits motion field by field (spec:AST-108 FR5)', () => { + it('a child that sets no easing keeps its base\u2019s --ease-standard, an explicit one included', () => { + const base = defineTheme({ + name: 'EasingBase', + motion: {fast: 150, medium: 300, ratio: 0.75, easing: 'ease-in'}, + tokens: {'--ease-standard': 'linear'}, + }); + expect(base.tokens['--ease-standard']).toBe('linear'); + const child = defineTheme({ + name: 'EasingChild', + extends: base, + motion: {tempo: 1.2}, + }); + expect(child.tokens['--ease-standard']).toBe('linear'); + // A child that sets its own easing generates it. + const own = defineTheme({ + name: 'EasingOwn', + extends: base, + motion: {fast: 150, medium: 300, ratio: 0.75, easing: 'ease-out'}, + }); + expect(own.tokens['--ease-standard']).toBe('ease-out'); + }); + + it('a grandchild that adds only a knob keeps the slow band its parent inherited', () => { + const base = defineTheme({ + name: 'SlowBase', + motion: {fast: 100, medium: 250, slow: 700, ratio: 0.75}, + }); + // The child's own ratio regenerates its fast and medium bands only. Its + // unrelated token gives it authored input tokens of its own. + const child = defineTheme({ + name: 'RatioChild', + extends: base, + motion: {fast: 100, medium: 250, ratio: 0.8}, + tokens: {'--radius-element': '6px'}, + }); + expect(child.tokens['--duration-slow-min']).toBe('525ms'); + for (const knob of [ + {character: 'expressive' as const}, + {tempo: 1.2}, + {reducedMotion: 'always' as const}, + ]) { + const grandchild = defineTheme({ + name: 'KnobGrandchild', + extends: child, + motion: knob, + }); + for (const band of [ + '--duration-slow-min', + '--duration-slow', + '--duration-slow-max', + ] as const) { + expect(grandchild.tokens[band]).toBe(child.tokens[band]); + } + } + }); + + it('an explicit --ease-standard without motion of its own keeps an inherited character\u2019s curves', () => { + const crisp = defineTheme({name: 'Crisp', motion: {character: 'crisp'}}); + const crispEase = crisp.tokens['--ease-entrance']; + const child = defineTheme({ + name: 'EaseChild', + extends: crisp, + tokens: {'--ease-standard': 'cubic-bezier(0.4, 0, 0.6, 1)'}, + }); + expect(child.tokens['--ease-standard']).toBe( + 'cubic-bezier(0.4, 0, 0.6, 1)', + ); + expect(child.tokens['--ease-entrance']).toBe(crispEase); + expect(child.tokens['--ease-move']).toBe(crispEase); + const adaptive = defineTheme({ + name: 'EaseRule', + motion: {character: 'expressive'}, + adaptations: { + rules: [ + { + when: {pointer: 'coarse'}, + value: { + tokens: {'--ease-standard': 'cubic-bezier(0.4, 0, 0.6, 1)'}, + }, + }, + ], + }, + }); + const rule = adaptive.__adaptationRules?.[0]; + expect(rule?.tokens['--ease-standard']).toBe( + 'cubic-bezier(0.4, 0, 0.6, 1)', + ); + expect(rule?.tokens['--ease-entrance']).toBeUndefined(); + expect(rule?.tokens['--ease-move']).toBeUndefined(); + }); + + it('a base duration scale keeps winning over a child\u2019s tempo', () => { + const base = defineTheme({ + name: 'ScaledBase', + motion: {fast: 100, medium: 250, slow: 800, ratio: 0.75}, + }); + const child = defineTheme({ + name: 'ScaledChild', + extends: base, + motion: {tempo: 1.2}, + }); + for (const name of [ + '--duration-fast', + '--duration-medium', + '--duration-slow', + ]) { + expect(child.tokens[name], name).toBe(base.tokens[name]); + } + }); + + it('a child\u2019s knobs regenerate over its base\u2019s explicit durations: base first, then generated', () => { + const base = defineTheme({ + name: 'ExplicitBase', + motion: {tempo: 0.8}, + tokens: {'--duration-medium': '300ms'}, + }); + const quicker = defineTheme({ + name: 'ExplicitChild', + extends: base, + motion: {tempo: 1.2}, + }); + expect(quicker.tokens['--duration-medium']).toBe( + `${Math.round(410 * 1.2)}ms`, + ); + expect(quicker.tokens['--duration-fast']).toBe( + `${Math.round(175 * 1.2)}ms`, + ); + const crisper = defineTheme({ + name: 'ExplicitCrisp', + extends: base, + motion: {character: 'crisp'}, + }); + // The inherited tempo regenerates it too. + expect(crisper.tokens['--duration-medium']).toBe( + `${Math.round(410 * 0.8)}ms`, + ); + expect(crisper.tokens['--duration-fast']).toBe( + `${Math.round(175 * 0.8)}ms`, + ); + expect(crisper.tokens['--ease-entrance']).toBe(CHARACTER_EASE.crisp); + }); +}); diff --git a/packages/core/src/theme/themeMotion.ts b/packages/core/src/theme/themeMotion.ts new file mode 100644 index 0000000000000..ee10333f26e62 --- /dev/null +++ b/packages/core/src/theme/themeMotion.ts @@ -0,0 +1,205 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeMotion.ts + * @input A theme's motion configuration: the three knobs (`tempo`, + * `character`, `reducedMotion`), the existing `MotionScaleConfig`, or both + * @output The resolved knobs, the motion tokens they generate, and + * `resolveThemeMotion`, the internal resolver the animator and server code + * read (`spec:AST-108` FR1, FR2, FR5, FR6) + * @position Theme utility; consumed by resolveThemeValues.ts. Pure: no DOM, + * no import-time work, safe on the server. + * + * `tempo` scales every motion duration token, clamped to 0.75–1.5. + * `character` picks the entrance and move curves. `reducedMotion: 'always'` + * marks the theme's root; so far motion-keyed adaptation rules and the press + * release fade read the mark, not yet components' other Reduce Motion styles. + * `'system'` follows each person's setting. No spring reads the knobs yet. With the + * defaults (tempo 1, `'standard'`, `'system'`) every token is today's. + * + * A `MotionScaleConfig` keeps generating its duration tokens, and its fields + * win over `tempo` (FR5); explicit `tokens` win over both in + * resolveThemeValues. + * + * SYNC: When modified, update: + * - /packages/core/src/theme/themeMotion.test.ts + * - /packages/core/src/theme/resolveThemeValues.ts + * - /packages/core/src/theme/defineTheme.ts (the `motion` field's docs) + */ + +import {expandMotionScale, type MotionScaleConfig} from './expandMotionScale'; +import { + resolveKnobs, + type MotionCharacter, + type ResolvedMotionKnobs, + type ThemeMotionKnobs, +} from './motionKnobs'; +import {durationDefaults, easeDefaults} from './tokens.stylex'; + +export { + TEMPO_RANGE, + type MotionCharacter, + type ResolvedMotionKnobs, + type ThemeMotionKnobs, + type ThemeReducedMotion, +} from './motionKnobs'; + +/** + * A theme's `motion`: the knobs, the existing `MotionScaleConfig`, or both. + * A `MotionScaleConfig`'s fields win over `tempo` for the durations they set. + */ +export type ThemeMotionConfig = + ThemeMotionKnobs | (MotionScaleConfig & ThemeMotionKnobs); + +/** + * The entrance and move curve at each character. `'standard'` is the theme's + * `--ease-standard`, so a theme that overrides that easing keeps its entrances + * on it. + */ +export const CHARACTER_EASE = { + crisp: 'cubic-bezier(0, 0, 0.2, 1)', + standard: null, + expressive: 'cubic-bezier(0.3, 1.2, 0.5, 1)', +} as const satisfies Record; + +/** The knobs of `config`, defaults filled in, `tempo` clamped. */ +export function resolveMotionKnobs( + config: ThemeMotionConfig | undefined, +): ResolvedMotionKnobs { + return resolveKnobs(config); +} + +function isScaleConfig( + config: ThemeMotionConfig, +): config is MotionScaleConfig & ThemeMotionKnobs { + return 'fast' in config && 'medium' in config && 'ratio' in config; +} + +/** A duration token's value in ms (`'175ms'` → 175). */ +function msOf(value: string): number { + return Number.parseFloat(value); +} + +/** + * The private custom property a theme with a `tempo` writes beside its + * duration tokens: the press release fade (`utils/interactionOverlay.stylex.ts`) + * multiplies its clock by it. + */ +export const MOTION_TEMPO_PROPERTY = '--_astryx-motion-tempo'; + +/** + * The private custom property the reduced-motion mark carries, 1 under it + * (`spec:AST-108` FR6), for a style that cannot key a selector on the mark, + * such as the press release's shared clock (utils/interactionOverlay.stylex.ts). + */ +export const REDUCED_MOTION_PROPERTY = '--_astryx-reduced-motion'; + +/** The duration tokens at `tempo`: today's values times it, to the ms. */ +export function durationTokensAt(tempo: number): Record { + const tokens: Record = {}; + for (const [name, value] of Object.entries(durationDefaults)) { + tokens[name] = `${Math.round(msOf(value) * tempo)}ms`; + } + return tokens; +} + +/** + * The private custom property holding a duration token's value before + * `tempo` scaled it (`--duration-slow` → `--_astryx-pre-tempo-duration-slow`). + * Busy loops (Spinner, Skeleton) read it, falling back to the token, so their + * timing follows a duration scale or an explicit token but never `tempo` + * (FR4). Private, like every property the knobs add beside the core tokens + * (`architecture:theme-tokens` INV1). + */ +export function preTempoName(token: string): string { + return token.replace(/^--duration-/, '--_astryx-pre-tempo-duration-'); +} + +/** + * The motion tokens `config` generates (FR2, FR5): `--duration-*` times + * `tempo`, with a `MotionScaleConfig`'s own durations winning, and the + * job-named `--ease-entrance` and `--ease-move` at the theme's character. + * A knob the config leaves out writes nothing, so a theme extending another + * keeps the base's value for it; a knob it sets is written even at its + * default, so a child can set a base's tempo back to 1. + */ +export function expandThemeMotion( + config: ThemeMotionConfig, +): Record { + const knobs = resolveMotionKnobs(config); + const tokens: Record = {}; + if (knobs.tempoIsSet) { + Object.assign(tokens, durationTokensAt(knobs.tempo)); + for (const [name, value] of Object.entries(durationDefaults)) { + tokens[preTempoName(name)] = value; + } + // The press release fade, a fixed clock in the stylesheet, scales by the + // tempo itself (FR4; `component:Button` FR15), read where it plays. + tokens[MOTION_TEMPO_PROPERTY] = String(knobs.tempo); + } + if (isScaleConfig(config)) { + const scale = expandMotionScale(config); + Object.assign(tokens, scale); + if (knobs.tempoIsSet) { + for (const [name, value] of Object.entries(scale)) { + if (name.startsWith('--duration-')) { + tokens[preTempoName(name)] = value; + } + } + } + } + const scaleEasing = + isScaleConfig(config) && config.easing != null ? config.easing : null; + const ease = + CHARACTER_EASE[knobs.character] ?? + (config.character != null + ? (scaleEasing ?? easeDefaults['--ease-standard']) + : scaleEasing); + if (ease != null) { + tokens['--ease-entrance'] = ease; + tokens['--ease-move'] = ease; + } + return tokens; +} + +/** What `resolveThemeMotion` returns for a theme. */ +export interface ThemeMotion { + /** The knobs, defaults filled in. */ + readonly knobs: ResolvedMotionKnobs; + /** Every `--duration-*` token's value. */ + readonly durations: Readonly>; + /** `--ease-standard`, `--ease-entrance` and `--ease-move`. */ + readonly easings: Readonly>; +} + +/** + * A theme's motion values, for the animator and for server code (FR6): the + * knobs, and the duration and easing tokens they resolve to with the theme's + * explicit tokens applied. Pure: it reads only `theme`. + */ +export function resolveThemeMotion(theme: { + readonly motion?: ThemeMotionConfig; + readonly __axes?: {readonly motion?: ThemeMotionConfig}; + readonly tokens?: Readonly>; +}): ThemeMotion { + // A defined theme's motion is its axes': its own merged with its base's. + const config = theme.__axes?.motion ?? theme.motion; + const knobs = resolveMotionKnobs(config); + const generated = config ? expandThemeMotion(config) : {}; + const value = (name: string, fallback: string): string => + theme.tokens?.[name] ?? generated[name] ?? fallback; + const durations: Record = {}; + for (const [name, fallback] of Object.entries(durationDefaults)) { + durations[name] = value(name, fallback); + } + const standard = value('--ease-standard', easeDefaults['--ease-standard']); + return { + knobs, + durations, + easings: { + '--ease-standard': standard, + '--ease-entrance': value('--ease-entrance', standard), + '--ease-move': value('--ease-move', standard), + }, + }; +} diff --git a/packages/core/src/theme/themeMotionMark.test.tsx b/packages/core/src/theme/themeMotionMark.test.tsx new file mode 100644 index 0000000000000..b102031ce6606 --- /dev/null +++ b/packages/core/src/theme/themeMotionMark.test.tsx @@ -0,0 +1,201 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeMotionMark.test.tsx + * @input vitest (jsdom), React Testing Library, react-dom/server, Theme, + * defineTheme and generateAdaptationCSS + * @output Checks `spec:AST-108` FR6's root mark: `reducedMotion: 'always'` + * marks the Theme wrapper (in a server render too, so nothing changes at + * hydration) and, for the root theme, ``, so portaled layers reduce + * their motion; `'system'` marks nothing; an ancestor's mark reaches every + * nested theme, whose component CSS stays reduced; a child theme inherits + * the setting from the theme it extends; the mark leaves with the theme + * @position Testing; validates Theme.tsx's use of themeMotion.ts + */ + +import {render} from '@testing-library/react'; +import {renderToString} from 'react-dom/server'; +import {afterEach, describe, expect, it} from 'vitest'; +import {defineTheme} from './defineTheme'; +import {generateAdaptationCSS} from './generateThemeRules'; +import {Theme} from './Theme'; +import {REDUCED_MOTION_PROPERTY} from './themeMotion'; + +const MARK = 'data-astryx-reduced-motion'; + +const always = defineTheme({ + name: 'StillAlways', + motion: {reducedMotion: 'always'}, +}); +const system = defineTheme({name: 'StillSystem', motion: {tempo: 1.2}}); + +afterEach(() => { + document.documentElement.removeAttribute(MARK); +}); + +describe('the reduced-motion mark (spec:AST-108 FR6)', () => { + it("'always' marks the wrapper and, at the root, ; the mark leaves with the theme", () => { + const {container, unmount} = render( + +

content

+
, + ); + expect( + container.querySelector('[data-astryx-theme]')?.hasAttribute(MARK), + ).toBe(true); + expect(document.documentElement.hasAttribute(MARK)).toBe(true); + unmount(); + expect(document.documentElement.hasAttribute(MARK)).toBe(false); + }); + + it('the mark carries its custom property, on the wrapper and at the root on , so styles that cannot key a selector on it read it (FR6)', () => { + const {container, unmount} = render( + +

content

+
, + ); + const wrapper = container.querySelector('[data-astryx-theme]'); + expect(wrapper).not.toBeNull(); + expect( + getComputedStyle(wrapper as Element) + .getPropertyValue(REDUCED_MOTION_PROPERTY) + .trim(), + ).toBe('1'); + expect( + document.documentElement.style.getPropertyValue(REDUCED_MOTION_PROPERTY), + ).toBe('1'); + unmount(); + expect( + document.documentElement.style.getPropertyValue(REDUCED_MOTION_PROPERTY), + ).toBe(''); + const {container: plain} = render( + +

content

+
, + ); + expect( + getComputedStyle(plain.querySelector('[data-astryx-theme]') as Element) + .getPropertyValue(REDUCED_MOTION_PROPERTY) + .trim(), + ).toBe(''); + expect( + document.documentElement.style.getPropertyValue(REDUCED_MOTION_PROPERTY), + ).toBe(''); + }); + + it("'system' marks nothing", () => { + const {container} = render( + +

content

+
, + ); + expect( + container.querySelector('[data-astryx-theme]')?.hasAttribute(MARK), + ).toBe(false); + expect(document.documentElement.hasAttribute(MARK)).toBe(false); + }); + + it('a nested theme marks its own wrapper only', () => { + const {container} = render( + + +

content

+
+
, + ); + const wrappers = container.querySelectorAll('[data-astryx-theme]'); + expect(wrappers[0].hasAttribute(MARK)).toBe(false); + expect(wrappers[1].hasAttribute(MARK)).toBe(true); + expect(document.documentElement.hasAttribute(MARK)).toBe(false); + }); + + it("an ancestor's 'always' reaches every nested theme: one set to 'system' or with no motion config cannot remove it", () => { + const rules = [ + { + when: {motion: 'reduce' as const}, + value: {tokens: {'--duration-fast': '0ms'}}, + }, + { + when: {motion: 'no-preference' as const}, + value: {tokens: {'--duration-fast': '200ms'}}, + }, + ]; + const nestedSystem = defineTheme({ + name: 'StillNestedSystem', + motion: {reducedMotion: 'system'}, + adaptations: {rules}, + }); + const nestedPlain = defineTheme({ + name: 'StillNestedPlain', + adaptations: {rules}, + }); + const {container} = render( + + +

system

+
+ +

plain

+
+
, + ); + const scopes = (css: string) => + [...css.matchAll(/@scope \(([^)]*(?:\([^)]*\)[^)]*)*)\) to/g)].map( + match => match[1], + ); + for (const theme of [nestedSystem, nestedPlain]) { + const wrapper = container.querySelector( + `[data-astryx-theme="${theme.name}"]`, + ) as Element; + // No mark of its own, and nothing that removes the ancestor's. + expect(wrapper.hasAttribute(MARK), theme.name).toBe(false); + expect(wrapper.closest(`[${MARK}]`), theme.name).not.toBeNull(); + // Its component CSS: the 'reduce' rule's marked block applies here, + // whatever the person's setting, and the 'no-preference' rule does not. + const [, marked, noPreference] = scopes( + generateAdaptationCSS(theme).component, + ); + expect(wrapper.matches(marked), theme.name).toBe(true); + expect(wrapper.matches(noPreference), theme.name).toBe(false); + expect(wrapper.querySelector('p')?.closest(`[${MARK}]`)).not.toBeNull(); + } + }); + + it('a theme extending an always theme keeps it unless it says otherwise', () => { + const child = defineTheme({ + name: 'StillChild', + extends: always, + motion: {tempo: 0.9}, + }); + const freed = defineTheme({ + name: 'StillFreed', + extends: always, + motion: {reducedMotion: 'system'}, + }); + const markOf = (theme: typeof child) => + render( + +

content

+
, + ) + .container.querySelector('[data-astryx-theme]') + ?.hasAttribute(MARK); + expect(markOf(child)).toBe(true); + expect(markOf(freed)).toBe(false); + }); + + it('the server renders the mark, so hydration changes nothing', () => { + const marked = renderToString( + +

content

+
, + ); + const plain = renderToString( + +

content

+
, + ); + expect(marked).toContain(`${MARK}=""`); + expect(plain).not.toContain(MARK); + }); +}); diff --git a/packages/core/src/theme/tokens.stylex.ts b/packages/core/src/theme/tokens.stylex.ts index df271111d747e..e33eca04da0e3 100644 --- a/packages/core/src/theme/tokens.stylex.ts +++ b/packages/core/src/theme/tokens.stylex.ts @@ -319,6 +319,16 @@ export type DurationVarName = keyof typeof durationDefaults; export const easeDefaults = { '--ease-standard': 'cubic-bezier(0.24, 1, 0.4, 1)', + /** + * Entrances: a layer or element arriving. A theme's `character` sets it + * (spec:AST-108 FR2); at `'standard'` it equals --ease-standard. + */ + '--ease-entrance': 'cubic-bezier(0.24, 1, 0.4, 1)', + /** + * Moves: an element changing place or size on screen. A theme's + * `character` sets it; at `'standard'` it equals --ease-standard. + */ + '--ease-move': 'cubic-bezier(0.24, 1, 0.4, 1)', } as const; export const easeVars = stylex.defineVars(easeDefaults); diff --git a/packages/core/src/utils/interactionOverlay.stylex.ts b/packages/core/src/utils/interactionOverlay.stylex.ts index 5e65e0e0d74bc..3525a488c32b8 100644 --- a/packages/core/src/utils/interactionOverlay.stylex.ts +++ b/packages/core/src/utils/interactionOverlay.stylex.ts @@ -116,8 +116,15 @@ export const pressConsts = stylex.defineConsts({ releaseMs: 200, }); -/** The release's clock as CSS. */ -const PRESS_RELEASE_DURATION = `${pressConsts.releaseMs}ms`; +/** + * The release's clock as CSS, scaled by the theme's `--_astryx-motion-tempo` + * (`spec:AST-108` FR4, `component:Button` FR15) up to 1.5, its maximum, or + * to 1 under a theme's `reducedMotion: 'always'` mark (FR6), so the 200 ms + * Reduce Motion cap holds. The controller's timer reads the same properties. + */ +const PRESS_RELEASE_DURATION = `calc(${pressConsts.releaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5 - 0.5 * var(--_astryx-reduced-motion, 0)))`; +/** The same clock under Reduce Motion: a tempo may shorten it, never lengthen it. */ +const PRESS_RELEASE_DURATION_REDUCED = `min(${pressConsts.releaseMs}ms, calc(${pressConsts.releaseMs}ms * var(--_astryx-motion-tempo, 1)))`; const ENABLED = ':where(:not(:disabled,[aria-disabled="true"]))'; const HOVER_HOVER = '@media (hover: hover)'; @@ -205,7 +212,10 @@ const pressReleaseAnimation = { }, animationDuration: { default: null, - '[data-astryx-press="fading"]': PRESS_RELEASE_DURATION, + '[data-astryx-press="fading"]': { + default: PRESS_RELEASE_DURATION, + '@media (prefers-reduced-motion: reduce)': PRESS_RELEASE_DURATION_REDUCED, + }, }, animationTimingFunction: { default: null, diff --git a/packages/core/src/utils/pressFeedback.test.ts b/packages/core/src/utils/pressFeedback.test.ts index 588625e8406d6..5e9bb54e882e2 100644 --- a/packages/core/src/utils/pressFeedback.test.ts +++ b/packages/core/src/utils/pressFeedback.test.ts @@ -12,6 +12,10 @@ */ import * as stylex from '@stylexjs/stylex'; +import {TEMPO_RANGE, expandThemeMotion} from '../theme/themeMotion'; +import {readFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import { durationToMs, @@ -317,6 +321,167 @@ describe('the release', () => { expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); }); + it('scales the fade by the tempo its stylesheet reads, a theme\u2019s or an adaptation rule\u2019s (spec:AST-108 FR4, component:Button FR15)', () => { + // A rule writes the tempo (a media-query adaptation, say); browsers + // inherit it from the theme, jsdom only reads it where it is declared. + const style = document.createElement('style'); + style.textContent = '.slow-row { --_astryx-motion-tempo: 1.5; }'; + document.head.appendChild(style); + rows[0].classList.add('slow-row'); + try { + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(PRESS_FADE_MS); + expect(rows[0].getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); + vi.advanceTimersByTime(PRESS_FADE_MS / 2); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + } finally { + style.remove(); + } + }); + + it('under Reduce Motion a tempo may shorten the fade but never lengthens it past 200 ms (spec:AST-108 FR4)', () => { + vi.stubGlobal( + 'matchMedia', + (query: string): MediaQueryList => + ({ + matches: /prefers-reduced-motion:\s*reduce/.test(query), + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as MediaQueryList, + ); + const style = document.createElement('style'); + style.textContent = + '.slow-row { --_astryx-motion-tempo: 1.5; } .quick-row { --_astryx-motion-tempo: 0.75; }'; + document.head.appendChild(style); + try { + rows[0].classList.add('slow-row'); + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(PRESS_FADE_MS); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + rows[0].classList.replace('slow-row', 'quick-row'); + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(PRESS_FADE_MS * 0.75); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + } finally { + rows[0].classList.remove('slow-row', 'quick-row'); + style.remove(); + vi.unstubAllGlobals(); + } + }); + + it('under a theme’s reducedMotion: "always" mark, with the setting off, the cap holds too (spec:AST-108 FR4, FR6)', () => { + // The mark's custom property, which a marked theme's wrapper and + // carry and every element under them inherits; jsdom only reads it where + // it is declared. + const style = document.createElement('style'); + style.textContent = + '.marked-slow { --_astryx-motion-tempo: 1.5; --_astryx-reduced-motion: 1; } .marked-quick { --_astryx-motion-tempo: 0.75; --_astryx-reduced-motion: 1; }'; + document.head.appendChild(style); + expect(window.matchMedia('(prefers-reduced-motion: reduce)').matches).toBe( + false, + ); + try { + rows[0].classList.add('marked-slow'); + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(PRESS_FADE_MS); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + rows[0].classList.replace('marked-slow', 'marked-quick'); + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(PRESS_FADE_MS * 0.75); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + } finally { + rows[0].classList.remove('marked-slow', 'marked-quick'); + style.remove(); + } + }); + + it('bounds the release’s tempo at its own maximum, so outside the mark the bound changes nothing', () => { + // The clock's 1.5 is TEMPO_RANGE.max, and every tempo a theme writes is + // clamped to it: a theme asking for 3 writes 1.5. + const source = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + 'interactionOverlay.stylex.ts', + ), + 'utf8', + ); + expect(source).toContain( + `min(var(--_astryx-motion-tempo, 1), ${TEMPO_RANGE.max} - ${TEMPO_RANGE.max - 1} * var(--_astryx-reduced-motion, 0))`, + ); + expect(expandThemeMotion({tempo: 3})['--_astryx-motion-tempo']).toBe( + String(TEMPO_RANGE.max), + ); + }); + + it('caps the stylesheet’s release fade under the mark’s custom property too', () => { + const source = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + 'interactionOverlay.stylex.ts', + ), + 'utf8', + ); + // Outside Reduce Motion the tempo may reach 1.5, its own maximum; under + // the mark (--_astryx-reduced-motion: 1) it stops at 1. + expect(source).toMatch( + /calc\(\$\{pressConsts\.releaseMs\}ms \* min\(var\(--_astryx-motion-tempo, 1\), 1\.5 - 0\.5 \* var\(--_astryx-reduced-motion, 0\)\)\)/, + ); + }); + + it('caps the stylesheet’s release fade the same way under Reduce Motion', () => { + const source = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + 'interactionOverlay.stylex.ts', + ), + 'utf8', + ); + expect(source).toMatch( + /min\(\$\{pressConsts\.releaseMs\}ms, calc\(\$\{pressConsts\.releaseMs\}ms \* var\(--_astryx-motion-tempo, 1\)\)\)/, + ); + expect(source).toMatch( + /'@media \(prefers-reduced-motion: reduce\)': PRESS_RELEASE_DURATION_REDUCED/, + ); + }); + + it('keeps the 200 ms fade without a tempo', () => { + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(PRESS_FADE_MS); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('times the stylesheet’s release fade by the same tempo', () => { + // The animation the attribute drives, scaled by the custom property a + // theme with a tempo writes beside its duration tokens. + const source = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + 'interactionOverlay.stylex.ts', + ), + 'utf8', + ); + expect(source).toMatch( + /calc\(\$\{pressConsts\.releaseMs\}ms \* min\(var\(--_astryx-motion-tempo, 1\),/, + ); + }); + it('answers a tap too quick for the delay, at the release', () => { press(rows[0], 'pointerdown', 10, 10); vi.advanceTimersByTime(20); @@ -418,6 +583,24 @@ describe('a phone or tablet paints a mouse or Space press itself', () => { expect(rows[1].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); }); + it('fades a mouse press by the theme\u2019s tempo too (spec:AST-108 FR4, component:Button FR15)', () => { + const style = document.createElement('style'); + style.textContent = '.slow-row { --_astryx-motion-tempo: 1.5; }'; + document.head.appendChild(style); + rows[1].classList.add('slow-row'); + try { + press(rows[1], 'pointerdown', 10, 100, 'mouse'); + press(rows[1], 'pointerup', 10, 100, 'mouse'); + vi.advanceTimersByTime(PRESS_FADE_MS); + expect(rows[1].getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); + vi.advanceTimersByTime(PRESS_FADE_MS / 2); + expect(rows[1].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + } finally { + rows[1].classList.remove('slow-row'); + style.remove(); + } + }); + it('keeps the paint while the mouse is dragged off the surface, as `:active` does', () => { press(rows[1], 'pointerdown', 10, 100, 'mouse'); press(document.body, 'pointermove', 400, 900, 'mouse'); diff --git a/packages/core/src/utils/pressFeedback.ts b/packages/core/src/utils/pressFeedback.ts index 3aada91bec313..dfb12d6271e5b 100644 --- a/packages/core/src/utils/pressFeedback.ts +++ b/packages/core/src/utils/pressFeedback.ts @@ -156,6 +156,26 @@ const TIMER_MS: Record, number> = { onset: PRESS_ONSET_MS, }; +/** + * The release fade's clock on `element`, read once, at the release, from the + * properties its stylesheet reads: the theme's `--_astryx-motion-tempo` and, + * under its `reducedMotion: 'always'` mark, `--_astryx-reduced-motion` + * (`spec:AST-108` FR4, FR6; `component:Button` FR15). Under Reduce Motion a + * tempo may shorten the fade, never lengthen it past 200 ms. + */ +function fadeMs(element: Element): number { + const style = getComputedStyle(element); + const value = style.getPropertyValue('--_astryx-motion-tempo').trim(); + const read = value === '' ? Number.NaN : Number(value); + const tempo = Number.isFinite(read) && read > 0 ? read : 1; + const scaled = PRESS_FADE_MS * tempo; + const reduced = + style.getPropertyValue('--_astryx-reduced-motion').trim() === '1' || + (typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches); + return reduced ? Math.min(scaled, PRESS_FADE_MS) : scaled; +} + const TIMER_EVENT = { fade: 'fadeEnd', flash: 'flashEnd', @@ -217,17 +237,22 @@ function apply(slot: Slot, step: PressStep): void { // A TIMER, never `requestAnimationFrame`: iOS throttles frame callbacks // while a scroll runs, so a frame-based delay would fire late, after the // scroll, and paint a control nobody is pressing any more. - live.timer = setTimeout(() => { - if (slot.live != null) { - slot.live.timer = null; - } - apply( - slot, - pressGestureStep(slot.live?.gesture ?? idlePressGesture(), { - kind: TIMER_EVENT[kind], - }), - ); - }, TIMER_MS[kind]); + live.timer = setTimeout( + () => { + if (slot.live != null) { + slot.live.timer = null; + } + apply( + slot, + pressGestureStep(slot.live?.gesture ?? idlePressGesture(), { + kind: TIMER_EVENT[kind], + }), + ); + }, + // The release fade follows the theme's tempo (`spec:AST-108` FR4, + // `component:Button` FR15); the onset delay and the tap flash do not. + kind === 'fade' ? fadeMs(live.element) : TIMER_MS[kind], + ); } } diff --git a/scripts/build-css.test.mjs b/scripts/build-css.test.mjs index 57f3db2ac42a2..358d4825ba554 100644 --- a/scripts/build-css.test.mjs +++ b/scripts/build-css.test.mjs @@ -242,7 +242,11 @@ describe('build-css astryx.css', () => { describe('the touch press release (utils/interactionOverlay.stylex.ts)', () => { /** `.2s`, `200ms`, `0.2s` → 200. */ const toMs = value => { - const match = value.trim().match(/^([\d.]+)(ms|s)$/); + // The release scales its base clock by the theme's tempo, capped under + // Reduce Motion (`calc( * …)`, `min(, …)`); it reads at tempo 1. + const match = value + .trim() + .match(/^(?:(?:calc|min)\(\s*)?([\d.]+)(ms|s)\b/); return match == null ? Number.NaN : Number(match[1]) * (match[2] === 's' ? 1000 : 1); @@ -272,12 +276,17 @@ describe('build-css astryx.css', () => { expect( fading.some(line => line.includes(`animation-name:${keyframes[1]}`)), ).toBe(true); - const durations = fading - .map(line => line.match(/animation-duration:\s*([^;}]+)/)) - .filter(Boolean) - .map(match => toMs(match[1])); - // One clock, the one the controller removes the attribute on. - expect(durations).toEqual([await readReleaseMs()]); + const durationsOf = reduced => + fading + .filter(line => line.includes('prefers-reduced-motion') === reduced) + .map(line => line.match(/animation-duration:\s*([^;}]+)/)?.[1]) + .filter(Boolean); + // One clock, the one the controller removes the attribute on, and its + // Reduce Motion arm, which a tempo never lengthens past it (FR4). + const releaseMs = await readReleaseMs(); + expect(durationsOf(false).map(toMs)).toEqual([releaseMs]); + expect(durationsOf(true).map(toMs)).toEqual([releaseMs]); + expect(durationsOf(true)[0]).toMatch(/^min\(/); // The onset is instant: nothing animates on the on arm. const on = lines().filter(line => line.includes('[data-astryx-press="on"]'), diff --git a/scripts/generate-token-docs.mjs b/scripts/generate-token-docs.mjs index 335d2cd3a3714..d4c64c90590a7 100644 --- a/scripts/generate-token-docs.mjs +++ b/scripts/generate-token-docs.mjs @@ -258,7 +258,7 @@ const groups = [ exportName: 'durationDefaults', title: 'Duration Tokens', description: - 'Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio.', + "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, @@ -267,7 +267,8 @@ const groups = [ previewType: 'easing-curve', exportName: 'easeDefaults', title: 'Easing Tokens', - description: 'Easing curves for animations and transitions.', + description: + "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, From a2d7f42bed0557097c50b9f8c389332ba9cedf5d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 19:30:54 -0700 Subject: [PATCH 2/6] fix(core): reducedMotion takes only 'system' until components follow 'always' A theme's reducedMotion: 'always' reached only its motion-keyed adaptation rules and the press release fade; components' own Reduce Motion styles, Spinner and Skeleton among them, did not follow it. A theme that set it got a promise most of the page did not keep. The public type now offers only 'system', the default, and the root mark, its custom property, the marked adaptation blocks and the press release's mark arm go with it. The docs, template, changeset and the authoring contract's unshipped note say so. --- .changeset/theme-motion-knobs.md | 2 +- docs/architecture/theme-authoring-contract.md | 12 +- .../cli/assets/docs/author-a-theme.doc.mjs | 4 +- packages/cli/assets/docs/motion.doc.mjs | 2 +- packages/cli/assets/theme.template.ts | 6 +- packages/core/src/theme/Theme.doc.mjs | 2 +- packages/core/src/theme/Theme.tsx | 38 +--- packages/core/src/theme/defineTheme.ts | 6 +- packages/core/src/theme/generateThemeRules.ts | 50 ++--- packages/core/src/theme/motionKnobs.ts | 14 +- .../core/src/theme/themeAdaptations.test.ts | 80 ------- packages/core/src/theme/themeAdaptations.ts | 25 +-- packages/core/src/theme/themeMotion.test.ts | 14 +- packages/core/src/theme/themeMotion.ts | 16 +- .../core/src/theme/themeMotionMark.test.tsx | 201 ------------------ .../src/utils/interactionOverlay.stylex.ts | 7 +- packages/core/src/utils/pressFeedback.test.ts | 51 +---- packages/core/src/utils/pressFeedback.ts | 12 +- 18 files changed, 59 insertions(+), 483 deletions(-) delete mode 100644 packages/core/src/theme/themeMotionMark.test.tsx diff --git a/.changeset/theme-motion-knobs.md b/.changeset/theme-motion-knobs.md index f452a34c1504a..57ea0f40ec82b 100644 --- a/.changeset/theme-motion-knobs.md +++ b/.changeset/theme-motion-knobs.md @@ -3,6 +3,6 @@ '@astryxdesign/cli': patch --- -[feat] A theme's `motion` takes three knobs beside the duration scale: `tempo` (0.75–1.5) scales every motion duration, `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the new `--ease-entrance` and `--ease-move` tokens, and `reducedMotion: 'always'` reduces motion for everyone; so far the theme's motion-keyed adaptation rules and the press release fade follow it, not yet components' other Reduce Motion styles. A theme without them renders exactly as before; a duration scale and explicit tokens win over the knobs, Spinner's and Skeleton's loops keep their timing under every tempo, and the press release fade follows tempo up to the 200 ms Reduce Motion cap, under the person's setting or the theme's `'always'`. Springs don't read the knobs yet. The motion, token and theme-authoring docs and the theme template describe the knobs. +[feat] A theme's `motion` takes three knobs beside the duration scale: `tempo` (0.75–1.5) scales every motion duration, `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the new `--ease-entrance` and `--ease-move` tokens, and `reducedMotion: 'system'`, the only value, follows each person's Reduce Motion setting. A theme without them renders exactly as before; a duration scale and explicit tokens win over the knobs, Spinner's and Skeleton's loops keep their timing under every tempo, and the press release fade follows tempo up to the 200 ms Reduce Motion cap under the person's setting. Springs don't read the knobs yet. The motion, token and theme-authoring docs and the theme template describe the knobs. @vjeux diff --git a/docs/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index 7f1f5f60924e3..74809cc936059 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -315,12 +315,12 @@ owner, lineage, collision, cycle, and legacy-unenrolled behavior remains shipped FR6): no spring reads the motion knobs yet. Until they land, that part of FR6 is current authority but not enforcement. -The reach of `reducedMotion: 'always'` is accepted but partly unshipped -(`spec:AST-108` FR6, INV15). Motion-keyed adaptation rules and the press -release fade key reduced motion on the mark; components' other Reduce Motion -styles, the animator, and other script readers of `prefers-reduced-motion` do -not yet. Until they do, that part of INV15 and FR6 is current authority but not -enforcement. +`reducedMotion: 'always'` is accepted but unshipped (`spec:AST-108` FR6, +INV15): `motion` takes only `reducedMotion: 'system'`, and nothing marks a +theme's root, until component styles, the animator, script readers of +`prefers-reduced-motion`, and motion-keyed adaptation rules key reduced motion +on the mark. Until then, that part of INV15 and FR6 is current authority but +not enforcement. ## Verification diff --git a/packages/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index 273aa7ecd7ca4..ef90e5b87583b 100644 --- a/packages/cli/assets/docs/author-a-theme.doc.mjs +++ b/packages/cli/assets/docs/author-a-theme.doc.mjs @@ -41,7 +41,7 @@ export const docs = { ['typography.scale', '--text-heading-*-size/weight/leading, --text-body-size/weight/leading', 'base (px), ratio'], ['typography.body/heading/code', '--font-family-body, --font-family-heading, --font-family-code', 'family, fallbacks?, url?, weight?'], ['radius', '--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat', 'base (px), multiplier (0–2)'], - ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system|always); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets. See {@link generic:motion}.'], + ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets. See {@link generic:motion}.'], ]}, {type: 'prose', text: 'For dark mode tuples in token values, see the Dark mode section of {@link generic:use-a-theme}.'}, ], @@ -106,7 +106,7 @@ export const docs = { {type: 'prose', text: "Precedence follows rule order. Root theme values apply first, then every matching rule in declaration order. A later rule may deliberately restore a root value. `onDark` and `onLight` media-surface overrides apply after adaptations and win on the same leaf."}, {type: 'prose', text: "`extends` preserves the base rule order and appends child rules. Inherited conditions use the child's effective breakpoint map. An empty child rule is a no-op, not a removal operator."}, {type: 'prose', text: "A rule may replace a theme-local token only when the exact name is already enrolled by root `localTokens` or an enrolled base. Component writes in a rule are validated exactly like root `components`: same targets, axes, and value domains. The one addition is that a rule may not be the only place a custom value is enrolled: a value that is valid only because a theme enrolls it generates unconditional type augmentation, so declare it on the root theme first and let rules restyle it. Built-in values need no root declaration. When rules can match together, their ordered portable and local token writes are validated as one effective graph; any reachable cycle fails before CSS is emitted."}, - {type: 'prose', text: "Adaptations compile to CSS media queries with no resize listener or styling rerender. Runtime and `{@command theme build}` use the same compiler, but only a built theme is present at first paint in an SSR app. Under `reducedMotion: 'always'`, the theme's own or an enclosing theme's, a `motion: 'reduce'` rule applies whatever the person's setting, and a `motion: 'no-preference'` rule does not."}, + {type: 'prose', text: "Adaptations compile to CSS media queries with no resize listener or styling rerender. Runtime and `{@command theme build}` use the same compiler, but only a built theme is present at first paint in an SSR app."}, ], }, { diff --git a/packages/cli/assets/docs/motion.doc.mjs b/packages/cli/assets/docs/motion.doc.mjs index 14b015a6dc4e6..4d82e6656ca64 100644 --- a/packages/cli/assets/docs/motion.doc.mjs +++ b/packages/cli/assets/docs/motion.doc.mjs @@ -158,7 +158,7 @@ const styles = stylex.create({ items: [ "`tempo` scales every motion duration: 1 is Astryx's own, 0.75 the quickest and 1.5 the slowest (values outside are clamped).", "`character` sets how lively motion is: `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, and `'expressive'` overshoots slightly on entrances and moves. Exits and fades never overshoot.", - "`reducedMotion: 'always'` reduces motion for everyone, as if each person had turned on Reduce Motion. Not everything follows it yet: the theme's motion-keyed adaptation rules and the press release fade do, and components' other Reduce Motion styles don't yet. `'system'`, the default, follows each person's setting. No value loosens Reduce Motion.", + "`reducedMotion: 'system'`, the default and only value, follows each person's Reduce Motion setting. No theme loosens Reduce Motion.", ], }, { diff --git a/packages/cli/assets/theme.template.ts b/packages/cli/assets/theme.template.ts index aa80d7492df89..6fc4ae60987d5 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -186,10 +186,8 @@ export const myTheme = defineTheme({ * tempo — 1 is Astryx's own; 0.75 the quickest, 1.5 the slowest. * character — 'crisp' | 'standard' | 'expressive': the entrance and move * curves. Exits and fades never overshoot. - * reducedMotion — 'always' reduces motion for everyone (so far the - * theme's motion-keyed adaptation rules and the press release fade, not - * yet components' other Reduce Motion styles); 'system' (the default) - * follows each person's setting. + * reducedMotion — 'system' (the default and only value) follows each + * person's Reduce Motion setting. * e.g. motion: {tempo: 0.85, character: 'crisp'} * Finger tracking, momentum, the keyboard, hover, the press's onset delay * and tap flash, and busy loops never follow them; the press release fade diff --git a/packages/core/src/theme/Theme.doc.mjs b/packages/core/src/theme/Theme.doc.mjs index 936a579b57076..bc0b858d6e116 100644 --- a/packages/core/src/theme/Theme.doc.mjs +++ b/packages/core/src/theme/Theme.doc.mjs @@ -69,7 +69,7 @@ export const docs = { type: 'DefinedTheme', required: true, description: - "Theme object to apply. Prefer built theme objects for static production themes; use runtime `defineTheme()` for dynamic themes. Its `motion` can set `character` (`'crisp' | 'standard' | 'expressive'`) and `reducedMotion` (`'system' | 'always'`).", + "Theme object to apply. Prefer built theme objects for static production themes; use runtime `defineTheme()` for dynamic themes. Its `motion` can set `character` (`'crisp' | 'standard' | 'expressive'`) and `reducedMotion` (`'system'`).", }, { name: 'mode', diff --git a/packages/core/src/theme/Theme.tsx b/packages/core/src/theme/Theme.tsx index ac7f0d7201fa8..4038421c37b5c 100644 --- a/packages/core/src/theme/Theme.tsx +++ b/packages/core/src/theme/Theme.tsx @@ -45,7 +45,6 @@ import {registerTheme} from './themeRegistry'; import {dataAttr} from '../naming'; import {ThemeContext} from './useTheme'; import {warnOnce} from '../utils/devWarning'; -import {REDUCED_MOTION_PROPERTY, resolveMotionKnobs} from './themeMotion'; /** * Theme provider props @@ -68,11 +67,6 @@ const wrapperStyles = stylex.create({ color: colorVars['--color-text-primary'], fontFamily: typographyVars['--font-family-body'], }, - // The reduced-motion mark's custom property (REDUCED_MOTION_PROPERTY in - // themeMotion.ts), which every element under the mark inherits (FR6). - reducedMotion: { - '--_astryx-reduced-motion': '1', - }, light: { colorScheme: 'light', }, @@ -199,16 +193,11 @@ function useThemeStyleInjection(theme: DefinedTheme): void { * * - 'light' | 'dark' → sets data-theme="light" | "dark" * - 'system' → removes data-theme (reset.css defaults to color-scheme: light dark) - * - * A theme with `reducedMotion: 'always'` also marks `` with - * `data-astryx-reduced-motion` and its custom property, so layers portaled - * outside the wrapper reduce their motion too (spec:AST-108 FR6). */ function useRootThemeSync( isNested: boolean, mode: ThemeMode, themeName: string, - reducesMotion: boolean, ): void { useIsomorphicLayoutEffect(() => { if (isNested) { @@ -227,22 +216,12 @@ function useRootThemeSync( // Sync theme name so @scope rules reach portals/fallback viewports. document.documentElement.setAttribute(dataAttr('theme'), themeName); - // The mark and its custom property, which portaled layers inherit. - if (reducesMotion) { - document.documentElement.setAttribute(dataAttr('reduced-motion'), ''); - document.documentElement.style.setProperty(REDUCED_MOTION_PROPERTY, '1'); - } else { - document.documentElement.removeAttribute(dataAttr('reduced-motion')); - document.documentElement.style.removeProperty(REDUCED_MOTION_PROPERTY); - } return () => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute(dataAttr('theme')); - document.documentElement.removeAttribute(dataAttr('reduced-motion')); - document.documentElement.style.removeProperty(REDUCED_MOTION_PROPERTY); }; - }, [isNested, mode, themeName, reducesMotion]); + }, [isNested, mode, themeName]); } // ============================================================================= @@ -272,13 +251,7 @@ export function Theme({ registerTheme(theme); useThemeStyleInjection(theme); - // spec:AST-108 FR6: 'always' marks the theme's root. So far motion-keyed - // adaptation rules and the press release fade key reduced motion on the - // mark; components' other Reduce Motion styles don't yet (a known gap in - // `architecture:theme-authoring-contract`). - const knobs = resolveMotionKnobs(theme.__axes?.motion); - const reducesMotion = knobs.reducedMotion === 'always'; - useRootThemeSync(isNested, mode, theme.name, reducesMotion); + useRootThemeSync(isNested, mode, theme.name); // Get color-scheme style const colorSchemeStyle = @@ -295,13 +268,8 @@ export function Theme({
{children}
diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index 008f060b89c5e..18f1e4018a1a1 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -296,10 +296,8 @@ export interface DefineThemeInput< * token; touch motion's springs don't read it yet. * - `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the * entrance and move curves; exits and fades never overshoot. - * - `reducedMotion` (`'system'` or `'always'`): `'always'` reduces motion - * for everyone; so far the theme's motion-keyed adaptation rules and the - * press release fade follow it, not yet components' other Reduce Motion - * styles. Nothing loosens a person's own setting. + * - `reducedMotion` (`'system'`, the default and only value) follows each + * person's Reduce Motion setting. Nothing loosens it. * * A motion scale computes duration min/max variants from base values and a * ratio (min = base × ratio, max = base / ratio); its durations win over diff --git a/packages/core/src/theme/generateThemeRules.ts b/packages/core/src/theme/generateThemeRules.ts index 462d11eaeb486..9d5e7cb543967 100644 --- a/packages/core/src/theme/generateThemeRules.ts +++ b/packages/core/src/theme/generateThemeRules.ts @@ -1751,7 +1751,7 @@ export function generateOnMediaCSS( } if (roleParts.length > 0) { adaptationMediaRoleBlocks.push( - adaptationBlocks(rule, roleParts, scopeSelector), + generateAdaptationMediaBlock(rule.query, roleParts, scopeSelector), ); } } @@ -1820,40 +1820,6 @@ function generateAdaptationMediaBlock( .join('\n\n')}\n }\n}`; } -/** The mark of `reducedMotion: 'always'` on a theme's root (spec:AST-108 FR6). */ -const REDUCED_MOTION_MARK = `[data-${dataAttrNamespace}-reduced-motion]`; - -/** - * An adaptation rule's blocks. A rule keyed on motion also honours the - * reduced-motion mark, on the theme's root or an ancestor's (spec:AST-108 - * FR6): a `'reduce'` rule gets a second block without the motion feature, - * scoped to a marked theme, so it applies whatever the person's setting, and - * a `'no-preference'` rule's scope leaves marked themes out. - */ -function adaptationBlocks( - rule: Pick, - parts: string[], - scopeSelector: string, -): string { - if (rule.when.motion === 'no-preference') { - return generateAdaptationMediaBlock( - rule.query, - parts, - `${scopeSelector}:not(${REDUCED_MOTION_MARK}):not(${REDUCED_MOTION_MARK} *)`, - ); - } - const block = generateAdaptationMediaBlock(rule.query, parts, scopeSelector); - if (rule.when.motion !== 'reduce' || rule.markQuery === undefined) { - return block; - } - const markedScope = `${scopeSelector}${REDUCED_MOTION_MARK}, ${REDUCED_MOTION_MARK} ${scopeSelector}`; - const marked = - rule.markQuery === '' - ? `@scope (${markedScope}) to (${THEME_SCOPE_TO}) {\n${parts.join('\n\n')}\n}` - : generateAdaptationMediaBlock(rule.markQuery, parts, markedScope); - return `${block}\n\n${marked}`; -} - /** * Generate CSS for a theme's ordered adaptation rules. * @@ -1900,7 +1866,7 @@ export function generateAdaptationCSS( if (parts.length === 0) { continue; } - blocks.push(adaptationBlocks(rule, parts, scopeSelector)); + blocks.push(generateAdaptationMediaBlock(rule.query, parts, scopeSelector)); } // Replay explicit role writes after every generated source alias. This keeps @@ -1919,7 +1885,9 @@ export function generateAdaptationCSS( roleEntries, ); if (roleRule !== null) { - blocks.push(adaptationBlocks(rule, [roleRule], scopeSelector)); + blocks.push( + generateAdaptationMediaBlock(rule.query, [roleRule], scopeSelector), + ); } } @@ -1948,7 +1916,13 @@ export function generateAdaptationCSS( {authoredOnly: true}, ); if (authoredWeightParts.length > 0) { - blocks.push(adaptationBlocks(rule, authoredWeightParts, scopeSelector)); + blocks.push( + generateAdaptationMediaBlock( + rule.query, + authoredWeightParts, + scopeSelector, + ), + ); } } } diff --git a/packages/core/src/theme/motionKnobs.ts b/packages/core/src/theme/motionKnobs.ts index cfd9b7c85d9be..d0fe8b8cacbba 100644 --- a/packages/core/src/theme/motionKnobs.ts +++ b/packages/core/src/theme/motionKnobs.ts @@ -18,8 +18,10 @@ /** How lively a theme's motion feels. */ export type MotionCharacter = 'crisp' | 'standard' | 'expressive'; -/** Whether a theme reduces motion for everyone. */ -export type ThemeReducedMotion = 'system' | 'always'; +/** + * Whose Reduce Motion setting a theme follows: `'system'`, each person's own. + */ +export type ThemeReducedMotion = 'system'; /** The three motion knobs a theme sets (`spec:AST-108` FR1). */ export interface ThemeMotionKnobs { @@ -35,8 +37,8 @@ export interface ThemeMotionKnobs { */ character?: MotionCharacter; /** - * `'always'` reduces motion for everyone; `'system'` follows each person's - * Reduce Motion setting. No value loosens Reduce Motion. Default `'system'`. + * `'system'` follows each person's Reduce Motion setting. It is the only + * value, and the default: no theme loosens Reduce Motion. */ reducedMotion?: ThemeReducedMotion; } @@ -79,7 +81,5 @@ export function resolveKnobs( config?.character != null && CHARACTERS.includes(config.character) ? config.character : 'standard'; - const reducedMotion = - config?.reducedMotion === 'always' ? 'always' : 'system'; - return {tempo, character, reducedMotion, tempoIsSet}; + return {tempo, character, reducedMotion: 'system', tempoIsSet}; } diff --git a/packages/core/src/theme/themeAdaptations.test.ts b/packages/core/src/theme/themeAdaptations.test.ts index fddad76366dcf..1f9de2df3ab28 100644 --- a/packages/core/src/theme/themeAdaptations.test.ts +++ b/packages/core/src/theme/themeAdaptations.test.ts @@ -185,89 +185,9 @@ describe('closed conditions and media queries', () => { expect(mediaPreludes(generateAdaptationCSS(theme).component)).toEqual([ '(width >= 768px) and (width < 1280px) and (pointer: coarse) and (prefers-contrast: more) and (prefers-reduced-motion: reduce)', - // The same rule under the reduced-motion mark (spec:AST-108 FR6). - '(width >= 768px) and (width < 1280px) and (pointer: coarse) and (prefers-contrast: more)', ]); }); - describe('a rule keyed on motion under the reduced-motion mark (spec:AST-108 FR6)', () => { - const MARK = '[data-astryx-reduced-motion]'; - const scopes = (css: string) => - [...css.matchAll(/@scope \(([^)]*(?:\([^)]*\)[^)]*)*)\) to/g)].map( - match => match[1], - ); - - it("a 'reduce' rule also applies inside a marked theme, whatever the person's setting", () => { - const css = generateAdaptationCSS( - defineTheme( - adaptationInput([ - { - when: {motion: 'reduce'}, - value: {tokens: {'--duration-fast': '0ms'}}, - }, - ]), - ), - ).component; - expect(mediaPreludes(css)).toEqual(['(prefers-reduced-motion: reduce)']); - expect(scopes(css)).toEqual([ - '[data-astryx-theme="adaptive"]', - `[data-astryx-theme="adaptive"]${MARK}, ${MARK} [data-astryx-theme="adaptive"]`, - ]); - expect(count(css, '--duration-fast: 0ms')).toBe(2); - }); - - it("keeps a 'reduce' rule's other conditions under the mark", () => { - const css = generateAdaptationCSS( - defineTheme( - adaptationInput([ - { - when: {motion: 'reduce', pointer: 'coarse'}, - value: {tokens: {'--duration-fast': '0ms'}}, - }, - ]), - ), - ).component; - expect(mediaPreludes(css)).toEqual([ - '(pointer: coarse) and (prefers-reduced-motion: reduce)', - '(pointer: coarse)', - ]); - }); - - it("leaves a marked theme out of a 'no-preference' rule", () => { - const css = generateAdaptationCSS( - defineTheme( - adaptationInput([ - { - when: {motion: 'no-preference'}, - value: {tokens: {'--duration-fast': '300ms'}}, - }, - ]), - ), - ).component; - expect(mediaPreludes(css)).toEqual([ - '(prefers-reduced-motion: no-preference)', - ]); - expect(scopes(css)).toEqual([ - `[data-astryx-theme="adaptive"]:not(${MARK}):not(${MARK} *)`, - ]); - }); - - it('changes nothing for a rule that is not keyed on motion', () => { - const css = generateAdaptationCSS( - defineTheme( - adaptationInput([ - { - when: {pointer: 'coarse'}, - value: {tokens: {'--duration-fast': '50ms'}}, - }, - ]), - ), - ).component; - expect(scopes(css)).toEqual(['[data-astryx-theme="adaptive"]']); - expect(css).not.toContain(MARK); - }); - }); - it.each([ [{}, /must contain at least one condition/], [{pointer: undefined}, /must contain at least one condition/], diff --git a/packages/core/src/theme/themeAdaptations.ts b/packages/core/src/theme/themeAdaptations.ts index 528001f0d8965..6112c6e9e39e3 100644 --- a/packages/core/src/theme/themeAdaptations.ts +++ b/packages/core/src/theme/themeAdaptations.ts @@ -189,14 +189,6 @@ export interface ResolvedThemeAdaptationRule { when: ThemeAdaptationCondition; /** CSS media-query prelude without the `@media` keyword. */ query: string; - /** - * For a rule keyed on `motion`: the prelude without the motion feature, - * empty when motion is the rule's only condition. Under the reduced-motion - * mark of `reducedMotion: 'always'` (on the theme's root or an ancestor's) - * a `'reduce'` rule applies whatever the person's setting, and a - * `'no-preference'` rule never does (`spec:AST-108` FR6). - */ - markQuery?: string; /** Concrete portable token writes produced by this rule. */ tokens: Record; /** Concrete theme-local token writes produced by this rule. */ @@ -687,7 +679,6 @@ function mediaQueryForCondition( ruleIndex: number, condition: ThemeAdaptationCondition, points: WidthBreakpoints, - options: {withoutMotion?: boolean} = {}, ): string { const path = `defineTheme("${themeName}").adaptations.rules[${ruleIndex}].when`; const parts: string[] = []; @@ -717,14 +708,11 @@ function mediaQueryForCondition( if (condition.contrast !== undefined) { parts.push(`(prefers-contrast: ${condition.contrast})`); } - if (condition.motion !== undefined && options.withoutMotion !== true) { + if (condition.motion !== undefined) { parts.push(`(prefers-reduced-motion: ${condition.motion})`); } if (parts.length === 0) { - if (options.withoutMotion === true) { - return ''; - } throw new Error(`${path} must contain at least one concrete condition.`); } return parts.join(' and '); @@ -1082,17 +1070,6 @@ export function resolveThemeAdaptationRules( rule.when, adaptations.widthBreakpoints, ), - ...(rule.when.motion !== undefined - ? { - markQuery: mediaQueryForCondition( - themeName, - index, - rule.when, - adaptations.widthBreakpoints, - {withoutMotion: true}, - ), - } - : {}), tokens: resolved.tokens, localTokens, components: input.typography?.scale diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts index 3ce563944a660..8c0a84ff61d95 100644 --- a/packages/core/src/theme/themeMotion.test.ts +++ b/packages/core/src/theme/themeMotion.test.ts @@ -54,13 +54,11 @@ describe('the motion knobs (spec:AST-108 FR1)', () => { }); }); - it('take reducedMotion always, and system otherwise', () => { - expect(resolveMotionKnobs({reducedMotion: 'always'}).reducedMotion).toBe( - 'always', - ); + it('take reducedMotion system, its only value', () => { expect(resolveMotionKnobs({reducedMotion: 'system'}).reducedMotion).toBe( 'system', ); + expect(resolveMotionKnobs({}).reducedMotion).toBe('system'); }); }); @@ -212,7 +210,7 @@ describe('existing motion configuration (spec:AST-108 FR5)', () => { { when: {pointer: 'coarse'}, value: { - motion: {reducedMotion: 'always'} as never, + motion: {reducedMotion: 'system'} as never, }, }, ], @@ -225,13 +223,13 @@ describe('existing motion configuration (spec:AST-108 FR5)', () => { describe('resolveThemeMotion (spec:AST-108 FR6)', () => { it('returns the knobs, durations and easings with explicit tokens applied', () => { const motion = resolveThemeMotion({ - motion: {tempo: 1.2, character: 'expressive', reducedMotion: 'always'}, + motion: {tempo: 1.2, character: 'expressive', reducedMotion: 'system'}, tokens: {'--duration-fast': '200ms'}, }); expect(motion.knobs).toEqual({ tempo: 1.2, character: 'expressive', - reducedMotion: 'always', + reducedMotion: 'system', tempoIsSet: true, }); expect(motion.durations['--duration-fast']).toBe('200ms'); @@ -473,7 +471,7 @@ describe('an extending theme inherits motion field by field (spec:AST-108 FR5)', for (const knob of [ {character: 'expressive' as const}, {tempo: 1.2}, - {reducedMotion: 'always' as const}, + {reducedMotion: 'system' as const}, ]) { const grandchild = defineTheme({ name: 'KnobGrandchild', diff --git a/packages/core/src/theme/themeMotion.ts b/packages/core/src/theme/themeMotion.ts index ee10333f26e62..480b83af29fcb 100644 --- a/packages/core/src/theme/themeMotion.ts +++ b/packages/core/src/theme/themeMotion.ts @@ -11,11 +11,10 @@ * no import-time work, safe on the server. * * `tempo` scales every motion duration token, clamped to 0.75–1.5. - * `character` picks the entrance and move curves. `reducedMotion: 'always'` - * marks the theme's root; so far motion-keyed adaptation rules and the press - * release fade read the mark, not yet components' other Reduce Motion styles. - * `'system'` follows each person's setting. No spring reads the knobs yet. With the - * defaults (tempo 1, `'standard'`, `'system'`) every token is today's. + * `character` picks the entrance and move curves. `reducedMotion` takes only + * `'system'`, which follows each person's setting. No spring reads the knobs + * yet. With the defaults (tempo 1, `'standard'`, `'system'`) every token is + * today's. * * A `MotionScaleConfig` keeps generating its duration tokens, and its fields * win over `tempo` (FR5); explicit `tokens` win over both in @@ -87,13 +86,6 @@ function msOf(value: string): number { */ export const MOTION_TEMPO_PROPERTY = '--_astryx-motion-tempo'; -/** - * The private custom property the reduced-motion mark carries, 1 under it - * (`spec:AST-108` FR6), for a style that cannot key a selector on the mark, - * such as the press release's shared clock (utils/interactionOverlay.stylex.ts). - */ -export const REDUCED_MOTION_PROPERTY = '--_astryx-reduced-motion'; - /** The duration tokens at `tempo`: today's values times it, to the ms. */ export function durationTokensAt(tempo: number): Record { const tokens: Record = {}; diff --git a/packages/core/src/theme/themeMotionMark.test.tsx b/packages/core/src/theme/themeMotionMark.test.tsx deleted file mode 100644 index b102031ce6606..0000000000000 --- a/packages/core/src/theme/themeMotionMark.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file themeMotionMark.test.tsx - * @input vitest (jsdom), React Testing Library, react-dom/server, Theme, - * defineTheme and generateAdaptationCSS - * @output Checks `spec:AST-108` FR6's root mark: `reducedMotion: 'always'` - * marks the Theme wrapper (in a server render too, so nothing changes at - * hydration) and, for the root theme, ``, so portaled layers reduce - * their motion; `'system'` marks nothing; an ancestor's mark reaches every - * nested theme, whose component CSS stays reduced; a child theme inherits - * the setting from the theme it extends; the mark leaves with the theme - * @position Testing; validates Theme.tsx's use of themeMotion.ts - */ - -import {render} from '@testing-library/react'; -import {renderToString} from 'react-dom/server'; -import {afterEach, describe, expect, it} from 'vitest'; -import {defineTheme} from './defineTheme'; -import {generateAdaptationCSS} from './generateThemeRules'; -import {Theme} from './Theme'; -import {REDUCED_MOTION_PROPERTY} from './themeMotion'; - -const MARK = 'data-astryx-reduced-motion'; - -const always = defineTheme({ - name: 'StillAlways', - motion: {reducedMotion: 'always'}, -}); -const system = defineTheme({name: 'StillSystem', motion: {tempo: 1.2}}); - -afterEach(() => { - document.documentElement.removeAttribute(MARK); -}); - -describe('the reduced-motion mark (spec:AST-108 FR6)', () => { - it("'always' marks the wrapper and, at the root, ; the mark leaves with the theme", () => { - const {container, unmount} = render( - -

content

-
, - ); - expect( - container.querySelector('[data-astryx-theme]')?.hasAttribute(MARK), - ).toBe(true); - expect(document.documentElement.hasAttribute(MARK)).toBe(true); - unmount(); - expect(document.documentElement.hasAttribute(MARK)).toBe(false); - }); - - it('the mark carries its custom property, on the wrapper and at the root on , so styles that cannot key a selector on it read it (FR6)', () => { - const {container, unmount} = render( - -

content

-
, - ); - const wrapper = container.querySelector('[data-astryx-theme]'); - expect(wrapper).not.toBeNull(); - expect( - getComputedStyle(wrapper as Element) - .getPropertyValue(REDUCED_MOTION_PROPERTY) - .trim(), - ).toBe('1'); - expect( - document.documentElement.style.getPropertyValue(REDUCED_MOTION_PROPERTY), - ).toBe('1'); - unmount(); - expect( - document.documentElement.style.getPropertyValue(REDUCED_MOTION_PROPERTY), - ).toBe(''); - const {container: plain} = render( - -

content

-
, - ); - expect( - getComputedStyle(plain.querySelector('[data-astryx-theme]') as Element) - .getPropertyValue(REDUCED_MOTION_PROPERTY) - .trim(), - ).toBe(''); - expect( - document.documentElement.style.getPropertyValue(REDUCED_MOTION_PROPERTY), - ).toBe(''); - }); - - it("'system' marks nothing", () => { - const {container} = render( - -

content

-
, - ); - expect( - container.querySelector('[data-astryx-theme]')?.hasAttribute(MARK), - ).toBe(false); - expect(document.documentElement.hasAttribute(MARK)).toBe(false); - }); - - it('a nested theme marks its own wrapper only', () => { - const {container} = render( - - -

content

-
-
, - ); - const wrappers = container.querySelectorAll('[data-astryx-theme]'); - expect(wrappers[0].hasAttribute(MARK)).toBe(false); - expect(wrappers[1].hasAttribute(MARK)).toBe(true); - expect(document.documentElement.hasAttribute(MARK)).toBe(false); - }); - - it("an ancestor's 'always' reaches every nested theme: one set to 'system' or with no motion config cannot remove it", () => { - const rules = [ - { - when: {motion: 'reduce' as const}, - value: {tokens: {'--duration-fast': '0ms'}}, - }, - { - when: {motion: 'no-preference' as const}, - value: {tokens: {'--duration-fast': '200ms'}}, - }, - ]; - const nestedSystem = defineTheme({ - name: 'StillNestedSystem', - motion: {reducedMotion: 'system'}, - adaptations: {rules}, - }); - const nestedPlain = defineTheme({ - name: 'StillNestedPlain', - adaptations: {rules}, - }); - const {container} = render( - - -

system

-
- -

plain

-
-
, - ); - const scopes = (css: string) => - [...css.matchAll(/@scope \(([^)]*(?:\([^)]*\)[^)]*)*)\) to/g)].map( - match => match[1], - ); - for (const theme of [nestedSystem, nestedPlain]) { - const wrapper = container.querySelector( - `[data-astryx-theme="${theme.name}"]`, - ) as Element; - // No mark of its own, and nothing that removes the ancestor's. - expect(wrapper.hasAttribute(MARK), theme.name).toBe(false); - expect(wrapper.closest(`[${MARK}]`), theme.name).not.toBeNull(); - // Its component CSS: the 'reduce' rule's marked block applies here, - // whatever the person's setting, and the 'no-preference' rule does not. - const [, marked, noPreference] = scopes( - generateAdaptationCSS(theme).component, - ); - expect(wrapper.matches(marked), theme.name).toBe(true); - expect(wrapper.matches(noPreference), theme.name).toBe(false); - expect(wrapper.querySelector('p')?.closest(`[${MARK}]`)).not.toBeNull(); - } - }); - - it('a theme extending an always theme keeps it unless it says otherwise', () => { - const child = defineTheme({ - name: 'StillChild', - extends: always, - motion: {tempo: 0.9}, - }); - const freed = defineTheme({ - name: 'StillFreed', - extends: always, - motion: {reducedMotion: 'system'}, - }); - const markOf = (theme: typeof child) => - render( - -

content

-
, - ) - .container.querySelector('[data-astryx-theme]') - ?.hasAttribute(MARK); - expect(markOf(child)).toBe(true); - expect(markOf(freed)).toBe(false); - }); - - it('the server renders the mark, so hydration changes nothing', () => { - const marked = renderToString( - -

content

-
, - ); - const plain = renderToString( - -

content

-
, - ); - expect(marked).toContain(`${MARK}=""`); - expect(plain).not.toContain(MARK); - }); -}); diff --git a/packages/core/src/utils/interactionOverlay.stylex.ts b/packages/core/src/utils/interactionOverlay.stylex.ts index 3525a488c32b8..9368b596e3c2e 100644 --- a/packages/core/src/utils/interactionOverlay.stylex.ts +++ b/packages/core/src/utils/interactionOverlay.stylex.ts @@ -118,11 +118,10 @@ export const pressConsts = stylex.defineConsts({ /** * The release's clock as CSS, scaled by the theme's `--_astryx-motion-tempo` - * (`spec:AST-108` FR4, `component:Button` FR15) up to 1.5, its maximum, or - * to 1 under a theme's `reducedMotion: 'always'` mark (FR6), so the 200 ms - * Reduce Motion cap holds. The controller's timer reads the same properties. + * (`spec:AST-108` FR4, `component:Button` FR15) up to 1.5, its maximum. The + * controller's timer reads the same property. */ -const PRESS_RELEASE_DURATION = `calc(${pressConsts.releaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5 - 0.5 * var(--_astryx-reduced-motion, 0)))`; +const PRESS_RELEASE_DURATION = `calc(${pressConsts.releaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5))`; /** The same clock under Reduce Motion: a tempo may shorten it, never lengthen it. */ const PRESS_RELEASE_DURATION_REDUCED = `min(${pressConsts.releaseMs}ms, calc(${pressConsts.releaseMs}ms * var(--_astryx-motion-tempo, 1)))`; diff --git a/packages/core/src/utils/pressFeedback.test.ts b/packages/core/src/utils/pressFeedback.test.ts index 5e9bb54e882e2..581d69649fd2d 100644 --- a/packages/core/src/utils/pressFeedback.test.ts +++ b/packages/core/src/utils/pressFeedback.test.ts @@ -380,37 +380,7 @@ describe('the release', () => { } }); - it('under a theme’s reducedMotion: "always" mark, with the setting off, the cap holds too (spec:AST-108 FR4, FR6)', () => { - // The mark's custom property, which a marked theme's wrapper and - // carry and every element under them inherits; jsdom only reads it where - // it is declared. - const style = document.createElement('style'); - style.textContent = - '.marked-slow { --_astryx-motion-tempo: 1.5; --_astryx-reduced-motion: 1; } .marked-quick { --_astryx-motion-tempo: 0.75; --_astryx-reduced-motion: 1; }'; - document.head.appendChild(style); - expect(window.matchMedia('(prefers-reduced-motion: reduce)').matches).toBe( - false, - ); - try { - rows[0].classList.add('marked-slow'); - press(rows[0], 'pointerdown', 10, 10); - vi.advanceTimersByTime(PRESS_ONSET_MS); - press(rows[0], 'pointerup', 10, 10); - vi.advanceTimersByTime(PRESS_FADE_MS); - expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); - rows[0].classList.replace('marked-slow', 'marked-quick'); - press(rows[0], 'pointerdown', 10, 10); - vi.advanceTimersByTime(PRESS_ONSET_MS); - press(rows[0], 'pointerup', 10, 10); - vi.advanceTimersByTime(PRESS_FADE_MS * 0.75); - expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); - } finally { - rows[0].classList.remove('marked-slow', 'marked-quick'); - style.remove(); - } - }); - - it('bounds the release’s tempo at its own maximum, so outside the mark the bound changes nothing', () => { + it('bounds the release’s tempo at its own maximum, so the bound changes nothing', () => { // The clock's 1.5 is TEMPO_RANGE.max, and every tempo a theme writes is // clamped to it: a theme asking for 3 writes 1.5. const source = readFileSync( @@ -421,29 +391,14 @@ describe('the release', () => { 'utf8', ); expect(source).toContain( - `min(var(--_astryx-motion-tempo, 1), ${TEMPO_RANGE.max} - ${TEMPO_RANGE.max - 1} * var(--_astryx-reduced-motion, 0))`, + `min(var(--_astryx-motion-tempo, 1), ${TEMPO_RANGE.max})`, ); expect(expandThemeMotion({tempo: 3})['--_astryx-motion-tempo']).toBe( String(TEMPO_RANGE.max), ); }); - it('caps the stylesheet’s release fade under the mark’s custom property too', () => { - const source = readFileSync( - join( - dirname(fileURLToPath(import.meta.url)), - 'interactionOverlay.stylex.ts', - ), - 'utf8', - ); - // Outside Reduce Motion the tempo may reach 1.5, its own maximum; under - // the mark (--_astryx-reduced-motion: 1) it stops at 1. - expect(source).toMatch( - /calc\(\$\{pressConsts\.releaseMs\}ms \* min\(var\(--_astryx-motion-tempo, 1\), 1\.5 - 0\.5 \* var\(--_astryx-reduced-motion, 0\)\)\)/, - ); - }); - - it('caps the stylesheet’s release fade the same way under Reduce Motion', () => { + it('caps the stylesheet’s release fade under Reduce Motion', () => { const source = readFileSync( join( dirname(fileURLToPath(import.meta.url)), diff --git a/packages/core/src/utils/pressFeedback.ts b/packages/core/src/utils/pressFeedback.ts index dfb12d6271e5b..59561d7d02571 100644 --- a/packages/core/src/utils/pressFeedback.ts +++ b/packages/core/src/utils/pressFeedback.ts @@ -158,10 +158,9 @@ const TIMER_MS: Record, number> = { /** * The release fade's clock on `element`, read once, at the release, from the - * properties its stylesheet reads: the theme's `--_astryx-motion-tempo` and, - * under its `reducedMotion: 'always'` mark, `--_astryx-reduced-motion` - * (`spec:AST-108` FR4, FR6; `component:Button` FR15). Under Reduce Motion a - * tempo may shorten the fade, never lengthen it past 200 ms. + * property its stylesheet reads: the theme's `--_astryx-motion-tempo` + * (`spec:AST-108` FR4, `component:Button` FR15). Under Reduce Motion a tempo + * may shorten the fade, never lengthen it past 200 ms. */ function fadeMs(element: Element): number { const style = getComputedStyle(element); @@ -170,9 +169,8 @@ function fadeMs(element: Element): number { const tempo = Number.isFinite(read) && read > 0 ? read : 1; const scaled = PRESS_FADE_MS * tempo; const reduced = - style.getPropertyValue('--_astryx-reduced-motion').trim() === '1' || - (typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches); + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; return reduced ? Math.min(scaled, PRESS_FADE_MS) : scaled; } From c5156c0c8da078048f32674635733352130a2465 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 20:47:02 -0700 Subject: [PATCH 3/6] fix(core): resolveThemeMotion resolves references, and every motion doc lists what the knobs never move resolveThemeMotion returned a motion token written as a reference as its var() string, where resolveThemeTokens resolves it (architecture:theme-tokens INV6). The var() resolver moves out of tokens.ts into tokenReferences.ts, unchanged, and resolveThemeMotion resolves through the defaults, the knobs' tokens and the theme's own; tokens.ts keeps evaluating color functions on it. The duration and easing token descriptions, the theme-authoring guide's motion row and INV15 now name the fixed list (spec:AST-108 FR4, FR7). --- docs/architecture/theme-authoring-contract.md | 6 +- .../cli/assets/docs/author-a-theme.doc.mjs | 2 +- .../assets/docs/tree/tokens-duration.doc.mjs | 4 +- .../cli/assets/docs/tree/tokens-ease.doc.mjs | 4 +- packages/core/src/theme/themeMotion.test.ts | 25 +++ packages/core/src/theme/themeMotion.ts | 42 ++++- packages/core/src/theme/tokenReferences.ts | 149 +++++++++++++++ packages/core/src/theme/tokens.ts | 173 ++---------------- scripts/generate-token-docs.mjs | 4 +- 9 files changed, 234 insertions(+), 175 deletions(-) create mode 100644 packages/core/src/theme/tokenReferences.ts diff --git a/docs/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index 74809cc936059..ba3c0a6229c22 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -206,7 +206,11 @@ it, typed as that theme's listed names. field: a duration-scale field or knob the child leaves unset keeps its base's value. Each duration's value before `tempo` is kept for busy-indicator loops, which never follow - `tempo`. `reducedMotion: 'always'` marks the theme's root, and what keys + `tempo`. Neither `tempo` nor `character` moves finger tracking, momentum, + the rubber band, the on-screen keyboard, the press model's onset delay, + cancel distance and tap flash, hover-intent delays, gesture thresholds, + busy-indicator loops, or the 200 ms Reduce Motion cap (`spec:AST-108` FR4). + `reducedMotion: 'always'` marks the theme's root, and what keys reduced motion on `prefers-reduced-motion` keys it on that mark as well (`spec:AST-108` FR6). An adaptation rule may set `tempo` and `character`, never `reducedMotion`. diff --git a/packages/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index ef90e5b87583b..59aed36653f90 100644 --- a/packages/cli/assets/docs/author-a-theme.doc.mjs +++ b/packages/cli/assets/docs/author-a-theme.doc.mjs @@ -41,7 +41,7 @@ export const docs = { ['typography.scale', '--text-heading-*-size/weight/leading, --text-body-size/weight/leading', 'base (px), ratio'], ['typography.body/heading/code', '--font-family-body, --font-family-heading, --font-family-code', 'family, fallbacks?, url?, weight?'], ['radius', '--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat', 'base (px), multiplier (0–2)'], - ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets. See {@link generic:motion}.'], + ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets, and explicit tokens win over both. The knobs never move finger tracking, momentum, the rubber band, the on-screen keyboard, the press\'s onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap. See {@link generic:motion}.'], ]}, {type: 'prose', text: 'For dark mode tuples in token values, see the Dark mode section of {@link generic:use-a-theme}.'}, ], diff --git a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs index b9b4fecae30f3..d06f55e34c7fa 100644 --- a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 100 }, "category": "foundations", - "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it.", + "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it. Never scaled by it: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", "sections": [ { "title": "Duration Tokens", "content": [ { "type": "prose", - "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it." + "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it. Never scaled by it: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap." }, { "type": "table", diff --git a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs index 213583f88b1f5..4bef2dd04f2ba 100644 --- a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 110 }, "category": "foundations", - "description": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character.", + "description": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character. Neither knob moves finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", "sections": [ { "title": "Easing Tokens", "content": [ { "type": "prose", - "text": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character." + "text": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character. Neither knob moves finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap." }, { "type": "table", diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts index 8c0a84ff61d95..d8e3c12ca4c3f 100644 --- a/packages/core/src/theme/themeMotion.test.ts +++ b/packages/core/src/theme/themeMotion.test.ts @@ -239,6 +239,31 @@ describe('resolveThemeMotion (spec:AST-108 FR6)', () => { expect(motion.easings['--ease-entrance']).toBe(CHARACTER_EASE.expressive); expect(motion.easings['--ease-standard']).toBe(STANDARD); }); + + it('resolves tokens written as references through the theme\u2019s tokens (architecture:theme-tokens INV6)', () => { + const motion = resolveThemeMotion({ + tokens: { + '--duration-fast': '90ms', + '--duration-medium': 'var(--duration-fast)', + '--duration-slow': 'var(--duration-unknown, 700ms)', + '--ease-standard': 'linear', + '--ease-entrance': 'var(--ease-standard)', + }, + }); + expect(motion.durations['--duration-medium']).toBe('90ms'); + expect(motion.durations['--duration-slow']).toBe('700ms'); + expect(motion.easings['--ease-entrance']).toBe('linear'); + expect(motion.easings['--ease-move']).toBe('linear'); + expect(JSON.stringify(motion)).not.toContain('var('); + }); + + it('resolves a reference to the theme\u2019s own local token', () => { + const motion = resolveThemeMotion({ + tokens: {'--duration-medium': 'var(--brand-beat)'}, + localTokens: {'--brand-beat': '333ms'}, + }); + expect(motion.durations['--duration-medium']).toBe('333ms'); + }); }); describe('a layer\u2019s own durations reach the busy loops under an enclosing tempo (spec:AST-108 FR4)', () => { diff --git a/packages/core/src/theme/themeMotion.ts b/packages/core/src/theme/themeMotion.ts index 480b83af29fcb..41ce163ef8c46 100644 --- a/packages/core/src/theme/themeMotion.ts +++ b/packages/core/src/theme/themeMotion.ts @@ -34,6 +34,7 @@ import { type ThemeMotionKnobs, } from './motionKnobs'; import {durationDefaults, easeDefaults} from './tokens.stylex'; +import {resolveTokenReferences} from './tokenReferences'; export { TEMPO_RANGE, @@ -167,31 +168,54 @@ export interface ThemeMotion { /** * A theme's motion values, for the animator and for server code (FR6): the * knobs, and the duration and easing tokens they resolve to with the theme's - * explicit tokens applied. Pure: it reads only `theme`. + * explicit tokens applied. A token written as a reference (`var(--name)`) + * resolves through the theme's tokens, as `resolveThemeTokens` resolves it + * (`architecture:theme-tokens` INV6). Pure: it reads only `theme`. */ export function resolveThemeMotion(theme: { readonly motion?: ThemeMotionConfig; readonly __axes?: {readonly motion?: ThemeMotionConfig}; - readonly tokens?: Readonly>; + readonly tokens?: Readonly>; + readonly localTokens?: Readonly>; }): ThemeMotion { // A defined theme's motion is its axes': its own merged with its base's. const config = theme.__axes?.motion ?? theme.motion; const knobs = resolveMotionKnobs(config); const generated = config ? expandThemeMotion(config) : {}; - const value = (name: string, fallback: string): string => - theme.tokens?.[name] ?? generated[name] ?? fallback; + // The graph the references resolve through: the defaults, the knobs' + // tokens, then the theme's own (a light/dark pair is not a motion value, so + // only plain strings take part). + const graph: Record = { + ...durationDefaults, + ...easeDefaults, + ...generated, + }; + for (const tokens of [theme.localTokens, theme.tokens]) { + for (const [name, value] of Object.entries(tokens ?? {})) { + if (typeof value === 'string') { + graph[name] = value; + } + } + } + const explicit = (name: string): boolean => + typeof theme.tokens?.[name] === 'string' || name in generated; + const resolved = resolveTokenReferences(graph); const durations: Record = {}; - for (const [name, fallback] of Object.entries(durationDefaults)) { - durations[name] = value(name, fallback); + for (const name of Object.keys(durationDefaults)) { + durations[name] = resolved[name]; } - const standard = value('--ease-standard', easeDefaults['--ease-standard']); + const standard = resolved['--ease-standard']; return { knobs, durations, easings: { '--ease-standard': standard, - '--ease-entrance': value('--ease-entrance', standard), - '--ease-move': value('--ease-move', standard), + '--ease-entrance': explicit('--ease-entrance') + ? resolved['--ease-entrance'] + : standard, + '--ease-move': explicit('--ease-move') + ? resolved['--ease-move'] + : standard, }, }; } diff --git a/packages/core/src/theme/tokenReferences.ts b/packages/core/src/theme/tokenReferences.ts new file mode 100644 index 0000000000000..6b903f3ca56ff --- /dev/null +++ b/packages/core/src/theme/tokenReferences.ts @@ -0,0 +1,149 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file tokenReferences.ts + * @input A flat map of token names to raw CSS values + * @output `resolveTokenReferences`, which follows `var(--name[, fallback])` + * references through the map, and the CSS parsing helpers it shares + * (`splitTopLevelComma`, `findMatchingParen`) + * @position Theme utility with no imports: tokens.ts resolves a theme's token + * graph on it (evaluating color functions as it goes), and themeMotion.ts + * resolves motion tokens written as references on it. Pure; safe on the + * server. + * + * SYNC: When modified, update: + * - /packages/core/src/theme/tokens.ts + * - /packages/core/src/theme/themeMotion.ts + */ + +/** + * Split the arguments of a CSS function body on the first top-level comma. + * Handles nested functions such as rgba(), color-mix(), var(), and quoted strings. + */ +export function splitTopLevelComma(input: string): [string, string] | null { + let depth = 0; + let quote: '"' | "'" | null = null; + let isEscaped = false; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (quote !== null) { + if (isEscaped) { + isEscaped = false; + } else if (char === '\\') { + isEscaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '(') { + depth++; + continue; + } + + if (char === ')') { + depth = Math.max(0, depth - 1); + continue; + } + + if (char === ',' && depth === 0) { + return [input.slice(0, i).trim(), input.slice(i + 1).trim()]; + } + } + + return null; +} + +/** Index of the `)` matching the `(` at `openIndex`, or -1 when unbalanced. */ +export function findMatchingParen(input: string, openIndex: number): number { + let depth = 0; + for (let i = openIndex; i < input.length; i++) { + const char = input[i]; + if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + if (depth === 0) { + return i; + } + } + } + return -1; +} + +/** + * Resolves every value in `raw` to a concrete one: each `var(--name[, + * fallback])` is replaced by `name`'s resolved value, or by its fallback when + * `raw` has no `name`, and is kept as written when neither exists or the + * references form a cycle. `evaluate` then runs on each substituted value + * (tokens.ts evaluates CSS color functions there). + */ +export function resolveTokenReferences( + raw: Readonly>, + evaluate: (expr: string) => string = expr => expr, +): Record { + const cache: Record = {}; + + // `seen` tracks the reference chain, so a cycle resolves to the literal + // reference instead of recursing forever. + const substitute = (expr: string, seen: Set): string => { + const start = expr.indexOf('var('); + if (start === -1) { + return expr; + } + const openIndex = start + 'var'.length; + const closeIndex = findMatchingParen(expr, openIndex); + if (closeIndex === -1) { + return expr; + } + + const inner = expr.slice(openIndex + 1, closeIndex); + const commaSplit = splitTopLevelComma(inner); + const name = (commaSplit ? commaSplit[0] : inner).trim(); + const fallback = commaSplit ? commaSplit[1] : null; + + let replacement: string; + if (seen.has(name)) { + replacement = expr.slice(start, closeIndex + 1); + } else if (name in raw) { + seen.add(name); + replacement = reference(name, seen); + seen.delete(name); + } else if (fallback !== null) { + replacement = substitute(fallback.trim(), seen); + } else { + replacement = expr.slice(start, closeIndex + 1); + } + + const rest = substitute(expr.slice(closeIndex + 1), seen); + return expr.slice(0, start) + replacement + rest; + }; + + const reference = (name: string, seen: Set): string => { + if (name in cache) { + return cache[name]; + } + const value = raw[name]; + if (value === undefined) { + return ''; + } + const resolved = evaluate( + value.includes('var(') ? substitute(value, seen) : value, + ); + cache[name] = resolved; + return resolved; + }; + + for (const name of Object.keys(raw)) { + reference(name, new Set()); + } + return cache; +} diff --git a/packages/core/src/theme/tokens.ts b/packages/core/src/theme/tokens.ts index d8ed43c1c0dfb..b08434f07164f 100644 --- a/packages/core/src/theme/tokens.ts +++ b/packages/core/src/theme/tokens.ts @@ -17,6 +17,7 @@ * * SYNC: When modified, update: * - /packages/core/src/theme/useTheme.ts + * - /packages/core/src/theme/tokenReferences.ts (the `var()` resolver) * - /packages/core/src/theme/index.ts */ @@ -28,6 +29,11 @@ import { } from './defineTheme'; import {parseColor, formatColor} from '../utils/color'; import {generatedScopedStatusRoleDefaults} from './generateThemeRules'; +import { + findMatchingParen, + resolveTokenReferences, + splitTopLevelComma, +} from './tokenReferences'; /** Resolved color mode used when choosing the side of light/dark token values. */ export type ResolvedThemeMode = 'light' | 'dark'; @@ -70,52 +76,6 @@ export const tokenVars: Record = Object.fromEntries( Object.keys(tokenDefaults).map(name => [name, tokenVar(name)]), ) as Record; -/** - * Split the arguments of a CSS function body on the first top-level comma. - * Handles nested functions such as rgba(), color-mix(), var(), and quoted strings. - */ -function splitTopLevelComma(input: string): [string, string] | null { - let depth = 0; - let quote: '"' | "'" | null = null; - let isEscaped = false; - - for (let i = 0; i < input.length; i++) { - const char = input[i]; - - if (quote !== null) { - if (isEscaped) { - isEscaped = false; - } else if (char === '\\') { - isEscaped = true; - } else if (char === quote) { - quote = null; - } - continue; - } - - if (char === '"' || char === "'") { - quote = char; - continue; - } - - if (char === '(') { - depth++; - continue; - } - - if (char === ')') { - depth = Math.max(0, depth - 1); - continue; - } - - if (char === ',' && depth === 0) { - return [input.slice(0, i).trim(), input.slice(i + 1).trim()]; - } - } - - return null; -} - /** Parse a CSS light-dark(light, dark) function into its two argument values. */ function parseLightDark(value: string): [light: string, dark: string] | null { const trimmed = value.trim(); @@ -158,27 +118,11 @@ function resolveXDSTokenValue( // Generated themes emit derived tokens as references to other tokens rather // than baked-in values, so a scoped CSS override of a base token re-themes the // subtree that references it. The CSS cascade resolves those references, but -// non-CSS consumers (canvas, SVG, data-viz) need the concrete value. These -// helpers replay the same resolution in JS: follow `var()` references through -// the resolved token map (iteratively, guarding against cycles) and evaluate -// the CSS color functions used by the theme generator against those values. - -/** Index of the `)` matching the `(` at `openIndex`, or -1 when unbalanced. */ -function findMatchingParen(input: string, openIndex: number): number { - let depth = 0; - for (let i = openIndex; i < input.length; i++) { - const char = input[i]; - if (char === '(') { - depth++; - } else if (char === ')') { - depth--; - if (depth === 0) { - return i; - } - } - } - return -1; -} +// non-CSS consumers (canvas, SVG, data-viz) need the concrete value. The same +// resolution replays in JS: tokenReferences.ts follows `var()` references +// through the resolved token map (guarding against cycles), and these helpers +// evaluate the CSS color functions used by the theme generator against those +// values. /** Split a color-mix component into its color and optional percentage. */ function parseMixComponent( @@ -290,93 +234,6 @@ function evaluateColorFunctions(expr: string): string { ); } -/** - * Substitute `var(--name[, fallback])` references with their resolved values. - * `seen` tracks the reference chain so cycles resolve to the literal reference - * instead of recursing forever. - */ -function substituteVars( - expr: string, - raw: Record, - cache: Record, - seen: Set, -): string { - const start = expr.indexOf('var('); - if (start === -1) { - return expr; - } - const openIndex = start + 'var'.length; - const closeIndex = findMatchingParen(expr, openIndex); - if (closeIndex === -1) { - return expr; - } - - const inner = expr.slice(openIndex + 1, closeIndex); - const commaSplit = splitTopLevelComma(inner); - const name = (commaSplit ? commaSplit[0] : inner).trim(); - const fallback = commaSplit ? commaSplit[1] : null; - - let replacement: string; - if (seen.has(name)) { - replacement = expr.slice(start, closeIndex + 1); - } else if (name in raw) { - seen.add(name); - replacement = resolveReference(name, raw, cache, seen); - seen.delete(name); - } else if (fallback !== null) { - replacement = substituteVars(fallback.trim(), raw, cache, seen); - } else { - replacement = expr.slice(start, closeIndex + 1); - } - - const rest = substituteVars(expr.slice(closeIndex + 1), raw, cache, seen); - return expr.slice(0, start) + replacement + rest; -} - -/** Fully resolve a single expression: substitute references, then evaluate colors. */ -function resolveExpression( - expr: string, - raw: Record, - cache: Record, - seen: Set, -): string { - if (!expr.includes('var(') && !expr.includes('color-mix(')) { - return expr; - } - const substituted = substituteVars(expr, raw, cache, seen); - return evaluateColorFunctions(substituted); -} - -/** Resolve one token by name, memoizing the result in `cache`. */ -function resolveReference( - name: string, - raw: Record, - cache: Record, - seen: Set, -): string { - if (name in cache) { - return cache[name]; - } - const value = raw[name]; - if (value === undefined) { - return ''; - } - const resolved = resolveExpression(value, raw, cache, seen); - cache[name] = resolved; - return resolved; -} - -/** Resolve every reference/color function in a raw token map to concrete values. */ -function resolveReferences( - raw: Record, -): Record { - const cache: Record = {}; - for (const name of Object.keys(raw)) { - resolveReference(name, raw, cache, new Set()); - } - return cache; -} - /** * Resolve all Astryx token values for a theme and effective color mode. * @@ -403,7 +260,7 @@ export function resolveThemeTokens( } if (theme == null) { - return resolveReferences(resolved); + return resolveTokenReferences(resolved, evaluateColorFunctions); } // A theme's local tokens resolve its references to them, then leave the @@ -436,9 +293,9 @@ export function resolveThemeTokens( } return Object.fromEntries( - Object.entries(resolveReferences(resolved)).filter( - ([key]) => !localKeys.has(key), - ), + Object.entries( + resolveTokenReferences(resolved, evaluateColorFunctions), + ).filter(([key]) => !localKeys.has(key)), ); } diff --git a/scripts/generate-token-docs.mjs b/scripts/generate-token-docs.mjs index d4c64c90590a7..836466567f2ee 100644 --- a/scripts/generate-token-docs.mjs +++ b/scripts/generate-token-docs.mjs @@ -258,7 +258,7 @@ const groups = [ exportName: 'durationDefaults', title: 'Duration Tokens', description: - "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it.", + "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it. Never scaled by it: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, @@ -268,7 +268,7 @@ const groups = [ exportName: 'easeDefaults', title: 'Easing Tokens', description: - "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character.", + "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character. Neither knob moves finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, From 71d0814fae7a93f6a8154325fd66b381d5fa1791 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 21:28:12 -0700 Subject: [PATCH 4/6] docs(core): every motion doc names the three knobs, their precedence and what they never move The duration and easing token descriptions name all three knobs and the precedence, each in its own words, and the theme template's fixed list adds the rubber band, the press's cancel distance and gesture thresholds (spec:AST-108 FR4, FR7). Token docs and the search goldens are regenerated. --- packages/cli/assets/docs/author-a-theme.doc.mjs | 2 +- packages/cli/assets/docs/tree/tokens-duration.doc.mjs | 4 ++-- packages/cli/assets/docs/tree/tokens-ease.doc.mjs | 4 ++-- packages/cli/assets/theme.template.ts | 7 ++++--- .../cli/test/__golden__/no-icon-set/search-icon-set.json | 2 +- .../cli/test/__golden__/no-icon-set/search-icon-set.txt | 2 +- scripts/generate-token-docs.mjs | 4 ++-- 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index 59aed36653f90..cb8cd1d30ca03 100644 --- a/packages/cli/assets/docs/author-a-theme.doc.mjs +++ b/packages/cli/assets/docs/author-a-theme.doc.mjs @@ -41,7 +41,7 @@ export const docs = { ['typography.scale', '--text-heading-*-size/weight/leading, --text-body-size/weight/leading', 'base (px), ratio'], ['typography.body/heading/code', '--font-family-body, --font-family-heading, --font-family-code', 'family, fallbacks?, url?, weight?'], ['radius', '--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat', 'base (px), multiplier (0–2)'], - ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets, and explicit tokens win over both. The knobs never move finger tracking, momentum, the rubber band, the on-screen keyboard, the press\'s onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap. See {@link generic:motion}.'], + ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets, and explicit tokens win over both. Fixed whatever the knobs say: drags and momentum, rubber band, keyboard, press onset/cancel/flash, hover intent, gesture thresholds, Spinner/Skeleton loops, the 200 ms Reduce Motion cap. See {@link generic:motion}.'], ]}, {type: 'prose', text: 'For dark mode tuples in token values, see the Dark mode section of {@link generic:use-a-theme}.'}, ], diff --git a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs index d06f55e34c7fa..7eb2e9ef68a85 100644 --- a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 100 }, "category": "foundations", - "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it. Never scaled by it: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", + "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's three `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` and `reducedMotion` (`system`, its only value) leave durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap.", "sections": [ { "title": "Duration Tokens", "content": [ { "type": "prose", - "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it. Never scaled by it: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap." + "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's three `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` and `reducedMotion` (`system`, its only value) leave durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap." }, { "type": "table", diff --git a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs index 4bef2dd04f2ba..70f5f911db836 100644 --- a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 110 }, "category": "foundations", - "description": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character. Neither knob moves finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", + "description": "Easing curves for animations and transitions. Of a theme's three `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; exits keep their own curve at every character. `tempo` and `reducedMotion` (`system`, its only value) leave curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character.", "sections": [ { "title": "Easing Tokens", "content": [ { "type": "prose", - "text": "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character. Neither knob moves finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap." + "text": "Easing curves for animations and transitions. Of a theme's three `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; exits keep their own curve at every character. `tempo` and `reducedMotion` (`system`, its only value) leave curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character." }, { "type": "table", diff --git a/packages/cli/assets/theme.template.ts b/packages/cli/assets/theme.template.ts index 6fc4ae60987d5..7b7963cd5f7b8 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -189,9 +189,10 @@ export const myTheme = defineTheme({ * reducedMotion — 'system' (the default and only value) follows each * person's Reduce Motion setting. * e.g. motion: {tempo: 0.85, character: 'crisp'} - * Finger tracking, momentum, the keyboard, hover, the press's onset delay - * and tap flash, and busy loops never follow them; the press release fade - * follows tempo, up to the 200 ms Reduce Motion cap. + * Finger tracking, momentum, the rubber band, the on-screen keyboard, + * hover-intent delays, gesture thresholds, the press's onset delay, cancel + * distance and tap flash, and busy loops never follow them; the press + * release fade follows tempo, up to the 200 ms Reduce Motion cap. * * A duration scale in ms wins over tempo for the durations it sets. Each of * fast/medium/slow also gets a -min and -max sibling computed with `ratio` diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json index e81065eb2bd86..2ae2cb5632112 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json @@ -3,7 +3,7 @@ "type": "search", "data": { "query": "icon set", - "matchCount": 370, + "matchCount": 372, "results": [ { "domain": "doc", diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt index fb62b9289644f..d8cd1954386a9 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt @@ -1,4 +1,4 @@ -Results for "icon set" (20 of 370) +Results for "icon set" (20 of 372) name: authoring/building-blocks/templates/build-the-template/template-assets/template-icons section: use-astryx-icon domain: doc diff --git a/scripts/generate-token-docs.mjs b/scripts/generate-token-docs.mjs index 836466567f2ee..dbb58c2ed06be 100644 --- a/scripts/generate-token-docs.mjs +++ b/scripts/generate-token-docs.mjs @@ -258,7 +258,7 @@ const groups = [ exportName: 'durationDefaults', title: 'Duration Tokens', description: - "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. A theme's `motion.tempo` (0.75–1.5) scales every one of them; a duration scale or an explicit token wins over it. Never scaled by it: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", + "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's three `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` and `reducedMotion` (`system`, its only value) leave durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, @@ -268,7 +268,7 @@ const groups = [ exportName: 'easeDefaults', title: 'Easing Tokens', description: - "Easing curves for animations and transitions. `--ease-entrance` and `--ease-move` follow a theme's `motion.character`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`. Exits keep their own curve at every character. Neither knob moves finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, the loops of busy indicators such as Spinner and Skeleton, and the 200 ms Reduce Motion cap.", + "Easing curves for animations and transitions. Of a theme's three `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; exits keep their own curve at every character. `tempo` and `reducedMotion` (`system`, its only value) leave curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, From 72881da62404a7fa2e81abfdfe9ef7311f3f0440 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 18:20:20 -0700 Subject: [PATCH 5/6] fix(core): theme motion keeps reducedMotion internal, exports its types, and names --ease-exit - reducedMotion leaves the public motion type until 'always' ships; every theme still resolves to 'system'. - ThemeMotionConfig, ThemeMotionKnobs and MotionCharacter are exported. - The motion guide's Easing section names all four curves and their jobs, and the motion guidance and theme template name --ease-exit for something leaving. --- .changeset/theme-motion-knobs.md | 2 +- docs/architecture/theme-authoring-contract.md | 3 ++- .../cli/assets/docs/author-a-theme.doc.mjs | 4 ++-- packages/cli/assets/docs/motion.doc.mjs | 7 +++---- .../assets/docs/tree/tokens-duration.doc.mjs | 4 ++-- .../cli/assets/docs/tree/tokens-ease.doc.mjs | 4 ++-- packages/cli/assets/theme.template.ts | 11 +++++----- .../__golden__/no-icon-set/docs-topics.json | 2 +- .../no-icon-set/search-icon-set.json | 2 +- .../no-icon-set/search-icon-set.txt | 2 +- packages/core/src/theme/Theme.doc.mjs | 2 +- packages/core/src/theme/defineTheme.ts | 6 +++--- .../core/src/theme/expandMotionScale.test.ts | 2 +- packages/core/src/theme/index.ts | 5 +++++ packages/core/src/theme/motionKnobs.ts | 13 +++++------- packages/core/src/theme/themeAdaptations.ts | 5 +++-- packages/core/src/theme/themeMotion.test.ts | 20 ++++++++----------- packages/core/src/theme/themeMotion.ts | 10 +++++----- scripts/generate-token-docs.mjs | 4 ++-- 19 files changed, 54 insertions(+), 54 deletions(-) diff --git a/.changeset/theme-motion-knobs.md b/.changeset/theme-motion-knobs.md index 57ea0f40ec82b..bf4202c9f85e0 100644 --- a/.changeset/theme-motion-knobs.md +++ b/.changeset/theme-motion-knobs.md @@ -3,6 +3,6 @@ '@astryxdesign/cli': patch --- -[feat] A theme's `motion` takes three knobs beside the duration scale: `tempo` (0.75–1.5) scales every motion duration, `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the new `--ease-entrance` and `--ease-move` tokens, and `reducedMotion: 'system'`, the only value, follows each person's Reduce Motion setting. A theme without them renders exactly as before; a duration scale and explicit tokens win over the knobs, Spinner's and Skeleton's loops keep their timing under every tempo, and the press release fade follows tempo up to the 200 ms Reduce Motion cap under the person's setting. Springs don't read the knobs yet. The motion, token and theme-authoring docs and the theme template describe the knobs. +[feat] A theme's `motion` takes two knobs beside the duration scale: `tempo` (0.75–1.5) scales every motion duration, and `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the new `--ease-entrance` and `--ease-move` tokens; `--ease-exit` stays the same at every character. Their types, `ThemeMotionConfig`, `ThemeMotionKnobs` and `MotionCharacter`, are exported. A theme without them renders exactly as before; a duration scale and explicit tokens win over the knobs, Spinner's and Skeleton's loops keep their timing under every tempo, and the press release fade follows tempo up to the 200 ms Reduce Motion cap under the person's setting. Springs don't read the knobs yet. The motion, token and theme-authoring docs and the theme template describe the knobs. @vjeux diff --git a/docs/architecture/theme-authoring-contract.md b/docs/architecture/theme-authoring-contract.md index ba3c0a6229c22..345531e579f73 100644 --- a/docs/architecture/theme-authoring-contract.md +++ b/docs/architecture/theme-authoring-contract.md @@ -320,7 +320,8 @@ FR6): no spring reads the motion knobs yet. Until they land, that part of FR6 is current authority but not enforcement. `reducedMotion: 'always'` is accepted but unshipped (`spec:AST-108` FR6, -INV15): `motion` takes only `reducedMotion: 'system'`, and nothing marks a +INV15): the public `motion` type has no `reducedMotion` field, every theme +follows each person's setting as `'system'` does, and nothing marks a theme's root, until component styles, the animator, script readers of `prefers-reduced-motion`, and motion-keyed adaptation rules key reduced motion on the mark. Until then, that part of INV15 and FR6 is current authority but diff --git a/packages/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index cb8cd1d30ca03..1ff25c6e11538 100644 --- a/packages/cli/assets/docs/author-a-theme.doc.mjs +++ b/packages/cli/assets/docs/author-a-theme.doc.mjs @@ -41,7 +41,7 @@ export const docs = { ['typography.scale', '--text-heading-*-size/weight/leading, --text-body-size/weight/leading', 'base (px), ratio'], ['typography.body/heading/code', '--font-family-body, --font-family-heading, --font-family-code', 'family, fallbacks?, url?, weight?'], ['radius', '--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat', 'base (px), multiplier (0–2)'], - ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive), reducedMotion? (system); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets, and explicit tokens win over both. Fixed whatever the knobs say: drags and momentum, rubber band, keyboard, press onset/cancel/flash, hover intent, gesture thresholds, Spinner/Skeleton loops, the 200 ms Reduce Motion cap. See {@link generic:motion}.'], + ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets, and explicit tokens win over both. Fixed whatever the knobs say: drags and momentum, rubber band, keyboard, press onset/cancel/flash, hover intent, gesture thresholds, Spinner/Skeleton loops, the 200 ms Reduce Motion cap. See {@link generic:motion}.'], ]}, {type: 'prose', text: 'For dark mode tuples in token values, see the Dark mode section of {@link generic:use-a-theme}.'}, ], @@ -58,7 +58,7 @@ export const docs = { ['iconVariants', 'Replaced, never merged: a child that sets iconVariants replaces the base list; a child that omits it inherits the list.'], ['indicators', 'Shallow-merged: child indicators override matching names from the base.'], ['onDark, onLight', "Deep-merged per surface: the base's resolved surface first, then the child's overrides."], - ['motion', "Field by field: the child's `fast`, `medium` and `ratio` regenerate the fast and medium duration tokens; `slow` and `easing` left unset keep the base theme's tokens, so an inherited slow band stays at the base's ratio. Each knob (`tempo`, `character`, `reducedMotion`) the child leaves unset keeps the base's."], + ['motion', "Field by field: the child's `fast`, `medium` and `ratio` regenerate the fast and medium duration tokens; `slow` and `easing` left unset keep the base theme's tokens, so an inherited slow band stays at the base's ratio. Each knob (`tempo`, `character`) the child leaves unset keeps the base's."], ['typography, radius, color', 'Child config replaces base entirely (these are scale inputs, not additive).'], ['adaptations', 'Width-breakpoint overrides merge by fixed name. Inherited ordered rules keep their relative order; child rules append and re-resolve against the child root axes.'], ]}, diff --git a/packages/cli/assets/docs/motion.doc.mjs b/packages/cli/assets/docs/motion.doc.mjs index 536d562f2cc05..326fa6ab186a9 100644 --- a/packages/cli/assets/docs/motion.doc.mjs +++ b/packages/cli/assets/docs/motion.doc.mjs @@ -50,7 +50,7 @@ export const docs = { content: [ { type: 'prose', - text: 'Two easing curves. `--ease-standard` decelerates, for things arriving and state changes. `--ease-exit` accelerates, for things leaving: it moves off from rest and leaves fast, so the travel stays visible for the whole duration.', + text: "Four easing curves. `--ease-standard` decelerates, for state changes. `--ease-entrance` is for things arriving and `--ease-move` for things moving on screen; a theme's `character` sets both, and at `standard` they equal `--ease-standard`. `--ease-exit` accelerates, for things leaving: it moves off from rest and leaves fast, so the travel stays visible for the whole duration, and it is the same at every `character`.", }, { type: 'token-ref', @@ -120,7 +120,7 @@ export const docs = { content: [ { type: 'prose', - text: "Set transition durations and easing from the motion tokens, so a theme that retunes motion retunes your animations too. Ease something arriving on `--ease-entrance` and something moving on screen on `--ease-move`, so it follows the theme's `character`.", + text: "Set transition durations and easing from the motion tokens, so a theme that retunes motion retunes your animations too. Ease something arriving on `--ease-entrance` and something moving on screen on `--ease-move`, so it follows the theme's `character`, and something leaving on `--ease-exit`, which stays the same at every `character`.", }, { type: 'code', @@ -150,7 +150,7 @@ const styles = stylex.create({ content: [ { type: 'prose', - text: "A theme sets how motion feels with three knobs in `defineTheme`'s `motion`. They drive the CSS motion tokens; the springs of touch motion don't read them yet.", + text: "A theme sets how motion feels with two knobs in `defineTheme`'s `motion`. They drive the CSS motion tokens; the springs of touch motion don't read them yet. Every theme follows each person's Reduce Motion setting; no theme loosens it.", }, { type: 'list', @@ -158,7 +158,6 @@ const styles = stylex.create({ items: [ "`tempo` scales every motion duration: 1 is Astryx's own, 0.75 the quickest and 1.5 the slowest (values outside are clamped).", "`character` sets how lively motion is: `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, and `'expressive'` overshoots slightly on entrances and moves. Exits and fades never overshoot.", - "`reducedMotion: 'system'`, the default and only value, follows each person's Reduce Motion setting. No theme loosens Reduce Motion.", ], }, { diff --git a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs index 7eb2e9ef68a85..052ad3b2411dd 100644 --- a/packages/cli/assets/docs/tree/tokens-duration.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-duration.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 100 }, "category": "foundations", - "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's three `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` and `reducedMotion` (`system`, its only value) leave durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap.", + "description": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's two `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` leaves durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap.", "sections": [ { "title": "Duration Tokens", "content": [ { "type": "prose", - "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's three `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` and `reducedMotion` (`system`, its only value) leave durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap." + "text": "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's two `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` leaves durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap." }, { "type": "table", diff --git a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs index 1f536e2a5d646..954690273d1b3 100644 --- a/packages/cli/assets/docs/tree/tokens-ease.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-ease.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 110 }, "category": "foundations", - "description": "Easing curves for animations and transitions. Of a theme's three `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; `--ease-exit` keeps its own curve at every character. `tempo` and `reducedMotion` (`system`, its only value) leave curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character.", + "description": "Easing curves for animations and transitions. Of a theme's two `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; `--ease-exit` keeps its own curve at every character. `tempo` leaves curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character.", "sections": [ { "title": "Easing Tokens", "content": [ { "type": "prose", - "text": "Easing curves for animations and transitions. Of a theme's three `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; `--ease-exit` keeps its own curve at every character. `tempo` and `reducedMotion` (`system`, its only value) leave curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character." + "text": "Easing curves for animations and transitions. Of a theme's two `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; `--ease-exit` keeps its own curve at every character. `tempo` leaves curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character." }, { "type": "table", diff --git a/packages/cli/assets/theme.template.ts b/packages/cli/assets/theme.template.ts index caf0e4108bf2a..376ce388e3ca1 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -181,13 +181,13 @@ export const myTheme = defineTheme({ radius: {base: 4, multiplier: 1}, /** - * Motion. Three knobs set how its CSS feels (touch springs don't read them + * Motion. Two knobs set how its CSS feels (touch springs don't read them * yet): * tempo — 1 is Astryx's own; 0.75 the quickest, 1.5 the slowest. * character — 'crisp' | 'standard' | 'expressive': the entrance and move - * curves. Exits and fades never overshoot. - * reducedMotion — 'system' (the default and only value) follows each - * person's Reduce Motion setting. + * curves (--ease-entrance, --ease-move). --ease-exit, the curve for + * something leaving, is the same at every character; exits and fades + * never overshoot. * e.g. motion: {tempo: 0.85, character: 'crisp'} * Finger tracking, momentum, the rubber band, the on-screen keyboard, * hover-intent delays, gesture thresholds, the press's onset delay, cancel @@ -231,7 +231,8 @@ export const myTheme = defineTheme({ // --radius-* none · inner · element · container · page · chat · full // --shadow-* low/med/high + inset-{hover,selected,success,warning,error} // --duration-* {fast,medium,slow} × {-min, base, -max} - // --ease-* standard (arriving, state) · exit (leaving) + // --ease-* standard (state) · entrance (arriving) · move (on + // screen) · exit (leaving) // --font-family-* body · heading · code // --font-size-* 4xs … 5xl // --font-weight-* normal · medium · semibold · bold diff --git a/packages/cli/test/__golden__/no-icon-set/docs-topics.json b/packages/cli/test/__golden__/no-icon-set/docs-topics.json index 93490de160335..4c0cbf5067740 100644 --- a/packages/cli/test/__golden__/no-icon-set/docs-topics.json +++ b/packages/cli/test/__golden__/no-icon-set/docs-topics.json @@ -202,7 +202,7 @@ "theme-motion", "best-practices" ], - "skeleton": "cb8eb7841f0abb1dc7e3f88c19f072e87f5e706f4cf44b6fb343492f7c12ff79", + "skeleton": "f1ffd7fc39f49d407c6caa563c25eadd8d79bf4d3c2fc6837f06297c37f714eb", "mentions": [] }, "principles": { diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json index 2ae2cb5632112..1fd75d8e92fb1 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json @@ -3,7 +3,7 @@ "type": "search", "data": { "query": "icon set", - "matchCount": 372, + "matchCount": 373, "results": [ { "domain": "doc", diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt index d8cd1954386a9..8267e31717d42 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt @@ -1,4 +1,4 @@ -Results for "icon set" (20 of 372) +Results for "icon set" (20 of 373) name: authoring/building-blocks/templates/build-the-template/template-assets/template-icons section: use-astryx-icon domain: doc diff --git a/packages/core/src/theme/Theme.doc.mjs b/packages/core/src/theme/Theme.doc.mjs index bc0b858d6e116..01122348a6798 100644 --- a/packages/core/src/theme/Theme.doc.mjs +++ b/packages/core/src/theme/Theme.doc.mjs @@ -69,7 +69,7 @@ export const docs = { type: 'DefinedTheme', required: true, description: - "Theme object to apply. Prefer built theme objects for static production themes; use runtime `defineTheme()` for dynamic themes. Its `motion` can set `character` (`'crisp' | 'standard' | 'expressive'`) and `reducedMotion` (`'system'`).", + "Theme object to apply. Prefer built theme objects for static production themes; use runtime `defineTheme()` for dynamic themes. Its `motion` can set `tempo` (0.75–1.5) and `character` (`'crisp' | 'standard' | 'expressive'`).", }, { name: 'mode', diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index 18f1e4018a1a1..6e467b86d2153 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -290,14 +290,14 @@ export interface DefineThemeInput< */ typography?: TypographyConfig; /** - * Motion configuration: three knobs (spec:AST-108), a motion scale, or both. + * Motion configuration: two knobs (spec:AST-108), a motion scale, or both. * * - `tempo` (default 1, clamped to 0.75–1.5) scales every motion duration * token; touch motion's springs don't read it yet. * - `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the * entrance and move curves; exits and fades never overshoot. - * - `reducedMotion` (`'system'`, the default and only value) follows each - * person's Reduce Motion setting. Nothing loosens it. + * + * Every theme follows each person's Reduce Motion setting; nothing loosens it. * * A motion scale computes duration min/max variants from base values and a * ratio (min = base × ratio, max = base / ratio); its durations win over diff --git a/packages/core/src/theme/expandMotionScale.test.ts b/packages/core/src/theme/expandMotionScale.test.ts index 3141cd356b048..f657a7b984212 100644 --- a/packages/core/src/theme/expandMotionScale.test.ts +++ b/packages/core/src/theme/expandMotionScale.test.ts @@ -139,7 +139,7 @@ describe('touch motion stays internal', () => { fs.readFileSync(path.resolve(__dirname, file), 'utf8'); expect(read('../index.ts')).not.toMatch(/from '\.\/motion/); expect(read('./index.ts')).not.toMatch( - /expandMotionRoles|motionScaleOf|MotionConfig|MotionRoleValues|MotionCurvePoints|registerMotionTable|motion\/role/, + /expandMotionRoles|motionScaleOf|\bMotionConfig\b|MotionRoleValues|MotionCurvePoints|registerMotionTable|motion\/role/, ); }); }); diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index 1e0d32f4b7f2d..f88df74ec7b93 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -104,6 +104,11 @@ export type {ColorScaleConfig, ColorScaleTokens} from './expandColorScale'; export {expandMotionScale} from './expandMotionScale'; export type {MotionScaleConfig, MotionScaleTokens} from './expandMotionScale'; +export type { + MotionCharacter, + ThemeMotionConfig, + ThemeMotionKnobs, +} from './themeMotion'; // Export token defaults and vars for use in custom components and themes export { diff --git a/packages/core/src/theme/motionKnobs.ts b/packages/core/src/theme/motionKnobs.ts index d0fe8b8cacbba..6744c1624db4c 100644 --- a/packages/core/src/theme/motionKnobs.ts +++ b/packages/core/src/theme/motionKnobs.ts @@ -2,9 +2,10 @@ /** * @file motionKnobs.ts - * @input A theme's motion knobs (`tempo`, `character`, `reducedMotion`) + * @input A theme's motion knobs (`tempo`, `character`) * @output Their types and `resolveMotionKnobs`, which fills in the defaults - * and clamps `tempo` (`spec:AST-108` FR1) + * and clamps `tempo` (`spec:AST-108` FR1). `reducedMotion` is not a public + * knob until `'always'` ships: every theme resolves to `'system'` * @position Theme utility with no imports: themeMotion.ts builds the motion * tokens on it, and the motion foundation reads a theme's knobs through it * without loading the token stylesheet. Pure; safe on the server and in @@ -20,10 +21,11 @@ export type MotionCharacter = 'crisp' | 'standard' | 'expressive'; /** * Whose Reduce Motion setting a theme follows: `'system'`, each person's own. + * Internal: no theme sets it until `'always'` ships (`spec:AST-108` FR6). */ export type ThemeReducedMotion = 'system'; -/** The three motion knobs a theme sets (`spec:AST-108` FR1). */ +/** The motion knobs a theme sets (`spec:AST-108` FR1). */ export interface ThemeMotionKnobs { /** * Scales every motion duration: 1 is Astryx's own, 0.75 the quickest and @@ -36,11 +38,6 @@ export interface ThemeMotionKnobs { * fades never overshoot. Default `'standard'`. */ character?: MotionCharacter; - /** - * `'system'` follows each person's Reduce Motion setting. It is the only - * value, and the default: no theme loosens Reduce Motion. - */ - reducedMotion?: ThemeReducedMotion; } /** The knobs with their defaults filled in and `tempo` clamped. */ diff --git a/packages/core/src/theme/themeAdaptations.ts b/packages/core/src/theme/themeAdaptations.ts index 6112c6e9e39e3..11afd78c1f37b 100644 --- a/packages/core/src/theme/themeAdaptations.ts +++ b/packages/core/src/theme/themeAdaptations.ts @@ -138,7 +138,7 @@ export interface ThemeAdaptationValue { * Motion axis overrides, completed from root metadata: scale fields, or the * `tempo` and `character` knobs (spec:AST-108 FR5). */ - motion?: Partial & Omit; + motion?: Partial & ThemeMotionKnobs; /** Portable semantic-token writes. */ tokens?: Partial>; /** Replacements for exact theme-local names enrolled by the root lineage. */ @@ -578,7 +578,8 @@ function completeMotion( rule: Partial & ThemeMotionKnobs, path: string, ): ThemeMotionConfig { - if (rule.reducedMotion !== undefined) { + // Not a public field, but a plain-JS caller may still pass it. + if ((rule as {reducedMotion?: unknown}).reducedMotion !== undefined) { throw new Error( `${path}.reducedMotion is a theme-wide setting: set it on the theme's own ` + `motion, not in an adaptation rule.`, diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts index d8e3c12ca4c3f..cf4802bfccb5a 100644 --- a/packages/core/src/theme/themeMotion.test.ts +++ b/packages/core/src/theme/themeMotion.test.ts @@ -5,7 +5,7 @@ * @input vitest, the theme motion knobs, defineTheme and expandMotionScale * @output Checks `spec:AST-108` FR1, FR2, FR5 and FR6's resolver: today's * tokens at the defaults, each tempo bound and the clamp, each character's - * curves with exits unchanged, `reducedMotion`, a `MotionScaleConfig` that + * curves with exits unchanged, `reducedMotion` kept internal, a `MotionScaleConfig` that * renders exactly as before and wins over `tempo`, explicit tokens winning * over both, adaptation rules setting the knobs, and `resolveThemeMotion` * @position Testing; validates themeMotion.ts and its use in defineTheme @@ -22,6 +22,7 @@ import { expandThemeMotion, resolveMotionKnobs, resolveThemeMotion, + type ThemeMotionKnobs, } from './themeMotion'; import {durationDefaults, easeDefaults} from './tokens.stylex'; @@ -54,11 +55,11 @@ describe('the motion knobs (spec:AST-108 FR1)', () => { }); }); - it('take reducedMotion system, its only value', () => { - expect(resolveMotionKnobs({reducedMotion: 'system'}).reducedMotion).toBe( - 'system', - ); + it('resolve reducedMotion to system, which no theme sets until always ships', () => { expect(resolveMotionKnobs({}).reducedMotion).toBe('system'); + // @ts-expect-error reducedMotion is not part of the public motion type. + const knobs: ThemeMotionKnobs = {reducedMotion: 'system'}; + expect(resolveMotionKnobs(knobs).reducedMotion).toBe('system'); }); }); @@ -68,7 +69,6 @@ describe('the CSS motion tokens (spec:AST-108 FR2)', () => { const explicit = expandThemeMotion({ tempo: 1, character: 'standard', - reducedMotion: 'system', }); expect(durationsMs(explicit)).toEqual(TODAY_MS); expect(explicit['--ease-entrance']).toBe(STANDARD); @@ -223,7 +223,7 @@ describe('existing motion configuration (spec:AST-108 FR5)', () => { describe('resolveThemeMotion (spec:AST-108 FR6)', () => { it('returns the knobs, durations and easings with explicit tokens applied', () => { const motion = resolveThemeMotion({ - motion: {tempo: 1.2, character: 'expressive', reducedMotion: 'system'}, + motion: {tempo: 1.2, character: 'expressive'}, tokens: {'--duration-fast': '200ms'}, }); expect(motion.knobs).toEqual({ @@ -493,11 +493,7 @@ describe('an extending theme inherits motion field by field (spec:AST-108 FR5)', tokens: {'--radius-element': '6px'}, }); expect(child.tokens['--duration-slow-min']).toBe('525ms'); - for (const knob of [ - {character: 'expressive' as const}, - {tempo: 1.2}, - {reducedMotion: 'system' as const}, - ]) { + for (const knob of [{character: 'expressive' as const}, {tempo: 1.2}]) { const grandchild = defineTheme({ name: 'KnobGrandchild', extends: child, diff --git a/packages/core/src/theme/themeMotion.ts b/packages/core/src/theme/themeMotion.ts index 41ce163ef8c46..6eddb2d30d355 100644 --- a/packages/core/src/theme/themeMotion.ts +++ b/packages/core/src/theme/themeMotion.ts @@ -2,8 +2,8 @@ /** * @file themeMotion.ts - * @input A theme's motion configuration: the three knobs (`tempo`, - * `character`, `reducedMotion`), the existing `MotionScaleConfig`, or both + * @input A theme's motion configuration: the knobs (`tempo`, `character`), + * the existing `MotionScaleConfig`, or both * @output The resolved knobs, the motion tokens they generate, and * `resolveThemeMotion`, the internal resolver the animator and server code * read (`spec:AST-108` FR1, FR2, FR5, FR6) @@ -11,9 +11,9 @@ * no import-time work, safe on the server. * * `tempo` scales every motion duration token, clamped to 0.75–1.5. - * `character` picks the entrance and move curves. `reducedMotion` takes only - * `'system'`, which follows each person's setting. No spring reads the knobs - * yet. With the defaults (tempo 1, `'standard'`, `'system'`) every token is + * `character` picks the entrance and move curves. Every theme follows each + * person's Reduce Motion setting (`reducedMotion` resolves to `'system'`). + * No spring reads the knobs yet. With the defaults (tempo 1, `'standard'`, `'system'`) every token is * today's. * * A `MotionScaleConfig` keeps generating its duration tokens, and its fields diff --git a/scripts/generate-token-docs.mjs b/scripts/generate-token-docs.mjs index 68b39c2976f43..03981dfa2b73f 100644 --- a/scripts/generate-token-docs.mjs +++ b/scripts/generate-token-docs.mjs @@ -258,7 +258,7 @@ const groups = [ exportName: 'durationDefaults', title: 'Duration Tokens', description: - "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's three `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` and `reducedMotion` (`system`, its only value) leave durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap.", + "Motion duration primitives. Three bands: fast (micro-interactions), medium (entrance/exit), slow (continuous). Min/max variants derive from base × ratio. Of a theme's two `motion` knobs, `tempo` (0.75–1.5) scales every duration here; `character` leaves durations alone. A duration scale beats `tempo` for the bands it sets, and explicit `tokens` beat both. `tempo` leaves these unscaled: finger tracking, momentum, the rubber band, the on-screen keyboard, the press's onset delay, cancel distance and tap flash, hover-intent delays, gesture thresholds, Spinner's and Skeleton's loops, and the 200 ms Reduce Motion cap.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, @@ -268,7 +268,7 @@ const groups = [ exportName: 'easeDefaults', title: 'Easing Tokens', description: - "Easing curves for animations and transitions. Of a theme's three `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; `--ease-exit` keeps its own curve at every character. `tempo` and `reducedMotion` (`system`, its only value) leave curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character.", + "Easing curves for animations and transitions. Of a theme's two `motion` knobs, `character` sets `--ease-entrance` and `--ease-move`: `crisp` decelerates without overshoot, `expressive` overshoots slightly, and at `standard` both equal `--ease-standard`; `--ease-exit` keeps its own curve at every character. `tempo` leaves curves alone. A duration scale's own fields win over the knobs, and explicit `tokens` win over everything. No curve here shapes motion that follows a finger or the system (dragging, momentum, the rubber band, the on-screen keyboard), and the press's clocks, hover-intent delays and gesture thresholds keep their timing whatever the character.", headers: ['Token', 'Value'], formatRow: (name, value) => [name, value], }, From 18b09c4a5c7ce756389b2d5bbd6402d1e4b48ef8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 20:50:22 -0700 Subject: [PATCH 6/6] fix(core): a partial duration scale beside a motion knob is refused, not dropped defineTheme refuses a motion that sets a duration scale field without fast, medium and ratio after inheritance, as adaptation rules already do, so explicit scale fields never silently lose to tempo (AST-108 FR5). AST-108's current-state impact names the reducedMotion known gap. --- docs/specs/AST-108-theme-motion/spec.md | 3 ++ packages/core/src/theme/defineTheme.ts | 34 +++++++++++++++++++++ packages/core/src/theme/themeMotion.test.ts | 23 ++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index 1a6adbeb91854..ad2cd5e6582d0 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -147,6 +147,9 @@ defineTheme({ - `defineTheme`'s `motion` widens to accept the three knobs beside the existing `MotionScaleConfig`; existing themes render exactly as before. +- Until `reducedMotion: 'always'` ships, the public `motion` type has no + `reducedMotion` field and every theme follows each person's setting, as + `'system'` does (the known gap in `architecture:theme-authoring-contract`). - 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 diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index 6e467b86d2153..49ac2f2c5d137 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -117,6 +117,7 @@ import { fontWeightDefaults, typeScaleDefaults, } from './tokens.stylex'; +import type {MotionScaleConfig} from './expandMotionScale'; import type {ThemeMotionConfig} from './themeMotion'; import type {RadiusScaleConfig} from './expandRadiusScale'; import type {ColorScaleConfig} from './expandColorScale'; @@ -699,6 +700,36 @@ function inheritedScaleTokens( ); } +/** + * Refuses a duration scale that is missing `fast`, `medium` or `ratio` after + * inheritance. The scale applies only when all three are present, so a partial + * one beside a knob (`{fast: 100, tempo: 1.2}`) would otherwise be dropped + * silently, though its fields win over `tempo` (spec:AST-108 FR5). Adaptation + * rules refuse the same in `completeMotion`. + */ +function assertCompleteMotionScale( + motion: ThemeMotionConfig, + themeName: string, +): void { + const scale = motion as Partial; + const writesScale = + scale.fast !== undefined || + scale.medium !== undefined || + scale.ratio !== undefined || + scale.slow !== undefined || + scale.easing !== undefined; + if ( + writesScale && + (scale.fast === undefined || + scale.medium === undefined || + scale.ratio === undefined) + ) { + throw new Error( + `defineTheme("${themeName}"): \`motion\` sets a duration scale field, so it must supply \`fast\`, \`medium\`, and \`ratio\` unless each missing field is inherited from \`extends\`.`, + ); + } +} + /** * Create an Astryx theme. * @@ -746,6 +777,9 @@ export function defineTheme< input.motion != null && baseMotion != null ? {...baseMotion, ...input.motion} : input.motion; + if (motion != null) { + assertCompleteMotionScale(motion, input.name); + } const inheritedScale = input.motion != null && base != null && baseMotion != null ? inheritedScaleTokens(base, baseMotion, input.motion) diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts index cf4802bfccb5a..7c0236bb1b3a0 100644 --- a/packages/core/src/theme/themeMotion.test.ts +++ b/packages/core/src/theme/themeMotion.test.ts @@ -181,6 +181,29 @@ describe('existing motion configuration (spec:AST-108 FR5)', () => { expect(reset.tokens['--ease-entrance']).toBe(STANDARD); }); + it('a partial duration scale beside a knob is refused, not dropped', () => { + for (const motion of [ + {fast: 100, tempo: 1.2}, + {fast: 100, medium: 250, slow: 700, tempo: 1}, + {easing: 'linear', character: 'standard' as const}, + ]) { + expect(() => defineTheme({name: 'Partial', motion})).toThrow( + /must supply `fast`, `medium`, and `ratio`/, + ); + } + // A field the child leaves out may come from its base. + const base = defineTheme({ + name: 'ScaleBase', + motion: {fast: 175, medium: 410, ratio: 0.75}, + }); + const child = defineTheme({ + name: 'ScaleChild', + extends: base, + motion: {fast: 100, tempo: 1.2}, + }); + expect(child.tokens['--duration-fast']).toBe('100ms'); + }); + it('a theme with no motion writes no motion token, so it renders as before', () => { const plain = defineTheme({name: 'Plain'}); expect(