diff --git a/.changeset/theme-motion-knobs.md b/.changeset/theme-motion-knobs.md new file mode 100644 index 0000000000000..bf4202c9f85e0 --- /dev/null +++ b/.changeset/theme-motion-knobs.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[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 f02be197843d7..345531e579f73 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,24 @@ 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`. 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`. This record does not own: @@ -249,6 +271,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 +282,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 +303,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 +315,17 @@ 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. + +`reducedMotion: 'always'` is accepted but unshipped (`spec:AST-108` FR6, +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 +not enforcement. ## Verification @@ -304,5 +340,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/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/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index 23871d1880d41..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', '--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); 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."], + ['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.'], ]}, @@ -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."}, ], }, { diff --git a/packages/cli/assets/docs/motion.doc.mjs b/packages/cli/assets/docs/motion.doc.mjs index 143ca4156a349..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.', + 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', @@ -137,12 +137,48 @@ 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 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', + 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.", + ], + }, + { + 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..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.", + "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." + "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 0b5d349623eea..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.", + "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." + "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", @@ -37,6 +37,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)" + ], [ "--ease-exit", "cubic-bezier(0.3, 0, 0.6, 0.6)" diff --git a/packages/cli/assets/docs/tree/tokens.doc.mjs b/packages/cli/assets/docs/tree/tokens.doc.mjs index 0cdb1efba7c10..7fbe9625f6def 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: 283 tokens across 15 categories. +// Total: 285 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 de652e1079ef7..376ce388e3ca1 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -181,12 +181,26 @@ 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. 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 (--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 + * 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` + * (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`. */ @@ -217,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/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 049e801ebecd9..4c0cbf5067740 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": "55910f8bc7dd8e5ba12067f98a5d75bd1d014e8f72c5da34ad8b695658a77236", + "skeleton": "f1ffd7fc39f49d407c6caa563c25eadd8d79bf4d3c2fc6837f06297c37f714eb", "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..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": 369, + "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 72beb71e54f56..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 369) +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/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..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.', + "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 adf01d996d5e3..49ac2f2c5d137 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -118,6 +118,7 @@ import { 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 +291,27 @@ 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. + * Motion configuration: two knobs (spec:AST-108), a motion scale, or both. * - * Explicit `tokens` overrides take precedence over motion-generated values. + * - `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. + * + * 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 + * `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 +320,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 +663,73 @@ 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]]] : [], + ), + ); +} + +/** + * 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. * @@ -682,13 +764,36 @@ 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; + if (motion != null) { + assertCompleteMotionScale(motion, input.name); + } + 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 +806,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/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 new file mode 100644 index 0000000000000..6744c1624db4c --- /dev/null +++ b/packages/core/src/theme/motionKnobs.ts @@ -0,0 +1,82 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file motionKnobs.ts + * @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). `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 + * 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'; + +/** + * 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 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; +} + +/** 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'; + return {tempo, character, reducedMotion: 'system', 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.ts b/packages/core/src/theme/themeAdaptations.ts index 6abc4384bc89f..11afd78c1f37b 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 & ThemeMotionKnobs; /** 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. */ @@ -570,13 +574,41 @@ 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 { + // 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.`, + ); + } + 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 +617,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, }; } @@ -990,7 +1023,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( diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts new file mode 100644 index 0000000000000..7c0236bb1b3a0 --- /dev/null +++ b/packages/core/src/theme/themeMotion.test.ts @@ -0,0 +1,620 @@ +// 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` 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 + */ + +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, + type ThemeMotionKnobs, +} 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('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'); + }); +}); + +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', + }); + 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 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( + 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: 'system'} 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'}, + tokens: {'--duration-fast': '200ms'}, + }); + expect(motion.knobs).toEqual({ + tempo: 1.2, + character: 'expressive', + reducedMotion: 'system', + 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); + }); + + 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)', () => { + 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}]) { + 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..6eddb2d30d355 --- /dev/null +++ b/packages/core/src/theme/themeMotion.ts @@ -0,0 +1,221 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeMotion.ts + * @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) + * @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. 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 + * 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'; +import {resolveTokenReferences} from './tokenReferences'; + +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 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. 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 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) : {}; + // 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 of Object.keys(durationDefaults)) { + durations[name] = resolved[name]; + } + const standard = resolved['--ease-standard']; + return { + knobs, + durations, + easings: { + '--ease-standard': 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.stylex.ts b/packages/core/src/theme/tokens.stylex.ts index d53393e8ed4c4..99ddf29a61e11 100644 --- a/packages/core/src/theme/tokens.stylex.ts +++ b/packages/core/src/theme/tokens.stylex.ts @@ -323,6 +323,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)', '--ease-exit': 'cubic-bezier(0.3, 0, 0.6, 0.6)', } as const; 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/packages/core/src/utils/interactionOverlay.stylex.ts b/packages/core/src/utils/interactionOverlay.stylex.ts index 5e65e0e0d74bc..9368b596e3c2e 100644 --- a/packages/core/src/utils/interactionOverlay.stylex.ts +++ b/packages/core/src/utils/interactionOverlay.stylex.ts @@ -116,8 +116,14 @@ 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. The + * controller's timer reads the same property. + */ +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)))`; const ENABLED = ':where(:not(:disabled,[aria-disabled="true"]))'; const HOVER_HOVER = '@media (hover: hover)'; @@ -205,7 +211,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..581d69649fd2d 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,122 @@ 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('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( + join( + dirname(fileURLToPath(import.meta.url)), + 'interactionOverlay.stylex.ts', + ), + 'utf8', + ); + expect(source).toContain( + `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 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 +538,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..59561d7d02571 100644 --- a/packages/core/src/utils/pressFeedback.ts +++ b/packages/core/src/utils/pressFeedback.ts @@ -156,6 +156,24 @@ const TIMER_MS: Record, number> = { onset: PRESS_ONSET_MS, }; +/** + * The release fade's clock on `element`, read once, at the release, from the + * 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); + 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 = + 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 +235,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..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.', + "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], }, @@ -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. 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], },