Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/theme-motion-knobs.md
Original file line number Diff line number Diff line change
@@ -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
49 changes: 43 additions & 6 deletions docs/architecture/theme-authoring-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

motionKnobs.ts holds the knob types, defaults and the tempo clamp that themeMotion.ts re-exports; worth listing it here and in the owning-code line too.

packages/core/src/theme/motionKnobs.ts,
packages/core/src/theme/mergeComponents.ts,
packages/core/src/theme/onMediaTokens.ts,
packages/core/src/theme/localTokens.ts,
Expand All @@ -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,
]
Expand All @@ -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,
]
---

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -249,6 +271,9 @@ public surface belongs to

## Owning code

- An internal, pure `resolveThemeMotion(theme)` returns a theme's motion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveThemeMotion returns knobs, durations and easings at this head; the role tables belong to the animator, which is not in this pull request. Dropping "and role tables" or keeping a known-gap sentence for them would keep the record accurate.

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,
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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 |
3 changes: 3 additions & 0 deletions docs/specs/AST-108-theme-motion/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/assets/docs/author-a-theme.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}.'},
],
Expand All @@ -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.'],
]},
Expand Down Expand Up @@ -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."},
],
},
{
Expand Down
42 changes: 39 additions & 3 deletions packages/cli/assets/docs/motion.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/assets/docs/tree/tokens-duration.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading