diff --git a/apps/storybook/stories/ThemeMotionCharacter.stories.tsx b/apps/storybook/stories/ThemeMotionCharacter.stories.tsx new file mode 100644 index 0000000000000..13adcef98a897 --- /dev/null +++ b/apps/storybook/stories/ThemeMotionCharacter.stories.tsx @@ -0,0 +1,267 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Button} from '@astryxdesign/core/Button'; +import {useMediaQuery} from '@astryxdesign/core/hooks'; +import { + SegmentedControl, + SegmentedControlItem, +} from '@astryxdesign/core/SegmentedControl'; +import {Theme, defineTheme} from '@astryxdesign/core/theme'; + +const meta: Meta = { + title: 'Core/Themes/Motion character', + parameters: {layout: 'padded'}, +}; + +export default meta; + +type Story = StoryObj; + +const TEMPOS = ['0.75', '1', '1.5'] as const; +type Tempo = (typeof TEMPOS)[number]; + +const CHARACTERS = ['standard', 'fluid'] as const; +type Character = (typeof CHARACTERS)[number]; + +/** + * Each character's column of the motion token table. `defineTheme` does not + * take `'fluid'` yet, so this preview writes both columns as explicit tokens. + */ +const COLUMNS: Record< + Character, + {entrance: string; move: string; mediumMinMs: number} +> = { + standard: { + entrance: 'cubic-bezier(0.24, 1, 0.4, 1)', + move: 'cubic-bezier(0.24, 1, 0.4, 1)', + mediumMinMs: 310, + }, + fluid: { + entrance: 'cubic-bezier(0, 0, 0, 1)', + move: 'cubic-bezier(0.2, 0, 0, 1)', + mediumMinMs: 410, + }, +}; + +/** The fast duration that exits play. It is the same at every character. */ +const FAST_MS = 175; + +function themeFor(character: Character, tempo: Tempo) { + const column = COLUMNS[character]; + const scale = Number(tempo); + return defineTheme({ + name: `motion-character-${character}-${tempo.replace('.', '-')}`, + tokens: { + '--ease-entrance': column.entrance, + '--ease-move': column.move, + '--duration-medium-min': `${column.mediumMinMs * scale}ms`, + '--duration-fast': `${FAST_MS * scale}ms`, + }, + }); +} + +const THEMES = Object.fromEntries( + CHARACTERS.map(character => [ + character, + Object.fromEntries( + TEMPOS.map(tempo => [tempo, themeFor(character, tempo)]), + ), + ]), +) as Record>>; + +const card = { + display: 'grid', + alignContent: 'start', + gap: 10, + padding: 10, + borderRadius: 12, + border: '1px solid var(--color-border)', + background: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + fontFamily: 'var(--font-family-body)', + fontSize: 13, +} as const; + +const heading = { + margin: 0, + fontSize: 14, + fontWeight: 600, + fontFamily: 'var(--font-family-body)', +} as const; + +const note = { + margin: 0, + fontSize: 12, + color: 'var(--color-text-secondary)', +} as const; + +const row = { + padding: '6px 8px', + borderRadius: 8, + background: 'var(--color-background-muted)', +} as const; + +const line = {margin: '4px 8px'} as const; + +const panel = { + position: 'absolute', + top: 34, + left: 0, + right: 0, + display: 'grid', + gap: 2, + padding: 4, + borderRadius: 10, + border: '1px solid var(--color-border)', + background: 'var(--color-background-card)', + boxShadow: '0 4px 12px rgb(0 0 0 / 0.16)', + transformOrigin: 'top left', +} as const; + +/** + * Details that expand, and the rows after them, which glide on the same + * motion: the move curve over the shortest medium duration. + */ +function ExpandAndGlide({ + isOpen, + isReduced, +}: { + isOpen: boolean; + isReduced: boolean; +}) { + return ( +
+
Shipping details
+
+
+

Ships in 2 business days.

+

Free returns for 30 days.

+
+
+
+ Order total +
+
Delivery
+
+ ); +} + +/** + * A menu that grows from its trigger on the entrance curve and leaves on the + * exit curve and duration. + */ +function Popover({isOpen, isReduced}: {isOpen: boolean; isReduced: boolean}) { + const timing = isOpen + ? 'var(--duration-medium-min) var(--ease-entrance)' + : 'var(--duration-exit) var(--ease-exit)'; + return ( +
+
Sort by
+
+
Newest
+
Price
+
Rating
+
+
+ ); +} + +function Column({ + character, + tempo, + isOpen, + isReduced, +}: { + character: Character; + tempo: Tempo; + isOpen: boolean; + isReduced: boolean; +}) { + const ms = Math.round(COLUMNS[character].mediumMinMs * Number(tempo)); + return ( + +
+

{character}

+

Expand, glide and menu: {ms} ms

+ + +
+
+ ); +} + +/** + * The same motions under the standard and fluid characters, side by side at + * phone width. Open plays them in both columns at once; the tempo scales both. + */ +export const StandardVsFluid: Story = { + render: function StandardVsFluid() { + const [tempo, setTempo] = useState('1'); + const [isOpen, setIsOpen] = useState(false); + const isReduced = useMediaQuery('(prefers-reduced-motion: reduce)'); + return ( +
+
+ setTempo(value as Tempo)}> + {TEMPOS.map(value => ( + + ))} + +
+ {isReduced ? ( +

Reduce Motion is on, so these change at once.

+ ) : null} +
+ {CHARACTERS.map(character => ( + + ))} +
+
+ ); + }, +}; diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index cffa239ea7f82..c34dd3f534a62 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -58,24 +58,27 @@ defineTheme({ ## Requirements -- **FR1 — Three motion knobs.** `defineTheme`'s `motion` takes +- **FR1 (proposed) — Three motion knobs.** `defineTheme`'s `motion` takes `{tempo, character, reducedMotion}`: - `tempo` is a number, default `1`, clamped to 0.75–1.5; it scales every motion duration; - - `character` is `'crisp'`, `'standard'` (default), or `'expressive'`; it - picks the entrance and move curves and the spring bounce. A continuous bounce - is not offered. No character turns a curve into a spring or plays a - platform-measured curve; which motion springs and which plays the curve - tokens is `spec:AST-094` FR45's, and tap-started expand and collapse, - including the animated-layout glide (`spec:AST-105` FR4), plays the curve - tokens; + - `character` is `'crisp'`, `'standard'` (default), `'expressive'`, or + `'fluid'`; it picks one column of the token table (FR8): the entrance and + move curves, and at `'fluid'` the medium duration bases. It sets no + spring (FR3). A continuous bounce is not offered. No character turns a + curve into a spring or plays a platform-measured curve; which motion + springs and which plays the curve tokens is `spec:AST-094` FR45's, and + tap-started expand and collapse, including the animated-layout glide + (`spec:AST-105` FR4), plays the curve tokens; - `reducedMotion` is `'system'` (default) or `'always'`; `'always'` applies `design:reduced-motion` DR1–DR3 for everyone, and `'system'` follows each person's setting. No value loosens Reduce Motion. -- **FR2 — The knobs generate the CSS motion tokens.** The theme's +- **FR2 (proposed) — The knobs generate the CSS motion tokens.** The theme's `--duration-*` tokens are their base values times `tempo`. `character` sets - only the job-named `--ease-entrance` and `--ease-move` tokens, declared in - `tokens.stylex.ts`. `--ease-standard`, which the existing exits read, and + the job-named `--ease-entrance` and `--ease-move` tokens, declared in + `tokens.stylex.ts`, and at `'fluid'` the bases of `--duration-medium-min`, + `--duration-medium`, and `--duration-medium-max` (FR8). `--ease-standard`, + which the existing exits read, and `--ease-exit`, whose default is `cubic-bezier(0.3, 0, 0.6, 0.6)`, are the same at every character, so exits stay quiet. With `tempo` 1 and `character` `'standard'`, the tokens keep their released values: `--duration-*` 130, 175, 230, 310, 410, @@ -83,19 +86,15 @@ defineTheme({ `cubic-bezier(0.24, 1, 0.4, 1)`; `--ease-entrance` and `--ease-move` equal `--ease-standard` at `'standard'`. The curves at `'crisp'` and `'expressive'` are a decelerate-only curve and a curve with a slight - overshoot. -- **FR3 — The animator's tables derive from the same knobs.** The animator's + overshoot; `'fluid'`'s curves come to rest with no overshoot. +- **FR3 (proposed) — The animator's tables follow `tempo`.** The animator's base tables, every role in `spec:AST-094` FR4, derive from the theme's - `tempo` and `character` at their source: a spring's duration scales by - `tempo`, and `character` offsets the role's measured bounce by an amount - times the role's fixed weight: `'standard'` adds nothing, and `'crisp'` and - `'expressive'` shift it down and up, so a role whose weight is zero, such as - an exit, a fade, or a full-screen move, keeps its measured bounce at every - character. With - `tempo` 1 and `character` `'standard'`, every role plays its measured value - exactly. A spring keeps its measured physics, scaled by `tempo` and shaped - by `character`; it does not follow a CSS duration or easing twin. The roles - and their weights stay internal. + `tempo` at their source: a spring takes `tempo` times as long and keeps its + measured bounce, and a duration role scales by `tempo`. `character` sets no + role, so every spring keeps its measured bounce at every character. With + `tempo` 1, every role plays its measured value exactly. A spring keeps its + measured physics, scaled by `tempo`; it does not follow a CSS duration or + easing twin. The roles stay internal. - **FR4a — Coordinated animations share one clock.** Animations that run together start and land together: they play the same motion role, or the CSS part is timed from the spring's computed settle time, and never as two @@ -146,6 +145,39 @@ defineTheme({ docs, the theme template, the theme-authoring guide, and `architecture:theme-authoring-contract` describe the three knobs, the fixed list, and the precedence before the implementation lands. +- **FR8 (proposed) — The fluid character's token column.** `character` + also takes `'fluid'` (FR1). Each character is one column of the token table + below, which motion on the curve tokens plays (`spec:AST-094` FR45). + `'fluid'` is unhurried and never overshoots: each medium duration takes the + next value of the released scale, and its curves settle over a long tail. + + | Token | `'crisp'` | `'standard'` | `'expressive'` | `'fluid'` | + | --------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------- | -------------------------------- | -------------------------------- | + | `--ease-entrance` | `cubic-bezier(0, 0, 0.2, 1)` | `cubic-bezier(0.24, 1, 0.4, 1)` | `cubic-bezier(0.3, 1.2, 0.5, 1)` | `cubic-bezier(0, 0, 0, 1)` | + | `--ease-move`, which the glide, swap, and edge reveal play over `--duration-medium-min` | `cubic-bezier(0, 0, 0.2, 1)` | `cubic-bezier(0.24, 1, 0.4, 1)` | `cubic-bezier(0.3, 1.2, 0.5, 1)` | `cubic-bezier(0.2, 0, 0, 1)` | + | `--ease-exit` | `cubic-bezier(0.3, 0, 0.6, 0.6)` | `cubic-bezier(0.3, 0, 0.6, 0.6)` | `cubic-bezier(0.3, 0, 0.6, 0.6)` | `cubic-bezier(0.3, 0, 0.6, 0.6)` | + | `--ease-standard` (fades and state changes) | `cubic-bezier(0.24, 1, 0.4, 1)` | `cubic-bezier(0.24, 1, 0.4, 1)` | `cubic-bezier(0.24, 1, 0.4, 1)` | `cubic-bezier(0.24, 1, 0.4, 1)` | + | `--duration-medium-min` base | 310 ms | 310 ms | 310 ms | 410 ms | + | `--duration-medium` base | 410 ms | 410 ms | 410 ms | 550 ms | + | `--duration-medium-max` base | 550 ms | 550 ms | 550 ms | 730 ms | + | Every other `--duration-*` base | Released value | Released value | Released value | Released value | + - Every duration token is its base times `tempo`. + - `'fluid'`'s entrance curve starts at speed and slows over most of its + length. Its move curve starts from rest, so an element on screen does not + jump. Both curves end at rest and stay within the travel. + - At `'fluid'`, the glide, swap, and edge reveal play the fluid move curve + over the fluid `--duration-medium-min` (`spec:AST-105` FR4). + - A busy loop keeps its period at `'fluid'`: it reads the base its theme's + duration scale sets, never a character's base (FR4). + - A CSS part timed with a spring keeps the spring's settle time at every + character (FR4a), not a medium duration token. + - Press feedback is the same at every character: the press clocks are + fixed (FR4), and the release fade scales by `tempo`. + - Reduce Motion is the same at every character (`design:reduced-motion` + DR1–DR3): no spring plays and nothing travels. A transition cross-fades + in 200 ms or less when the primary pointer is coarse or the release of a + touch drag starts it, and changes at once otherwise; a throw still + coasts. ## Current-state impact @@ -175,18 +207,29 @@ defineTheme({ providers), `architecture:theme-authoring-contract` (the internal motion resolver), `component:Button` FR15 (its release fade follows `tempo`), the draft `design:motion` DR3, and `spec:AST-090` are amended to match. +- FR1, FR2, FR3, FR8, DEC-3, and DEC-4 are proposed. `spec:AST-094` FR4, + FR45, and DEC-15 still give `character` a bounce offset through per-role + weights, and `architecture:theme-authoring-contract` INV15 still lists + three characters that set only curves; each is amended to match. +- No spring reads `character` today, so the bounce offset FR3 drops changes + no shipped motion. +- `defineTheme` does not accept `'fluid'` yet. Its curve table must set + `--ease-entrance` and `--ease-move` to different values and write the three + medium bases, while busy loops keep reading the scale's bases. No Core + component reads `--ease-entrance` or `--ease-move` today. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| -------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR2 | Theme unit tests over generated CSS | each `tempo` bound and 1; each `character`; `reducedMotion` both values; no `motion` | a token off its formula, the released tokens changed at the defaults, an exit curve that changes, or a value outside a clamp fails | -| FR3 | Tests that the CSS tokens and the animator's tables derive from the same knob values | each role at the defaults and at each knob value; an adaptation rule that sets `tempo` or `character` | a role off its measured value at the defaults, a role that ignores a knob it follows, or a weight-0 role whose bounce changes with `character` fails | -| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, an element's entrance and exit, a sheet and its scrim, a menu's grow and content, a CSS fade with a spring | paired animations that end more than one frame apart fail | -| FR4 | Unit tests of each fixed item | finger tracking, momentum, rubber band, keyboard, press and hover clocks, loops, Reduce Motion cap; Spinner and Skeleton loops at each tempo | a fixed item that moves with `tempo` or `character`, or a cross-fade longer than 200 ms, fails | -| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | -| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that restores motion under an ancestor's `'always'`, fails | -| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | +| Contract | Verification | Representative states | Mutation or failure expectation | +| -------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR2 | Theme unit tests over generated CSS | each `tempo` bound and 1; each `character`, `'fluid'` included (proposed); `reducedMotion` both values; no `motion` | a token off its formula, the released tokens changed at the defaults, an exit curve that changes, a medium base off FR8's column (proposed), or a value outside a clamp fails | +| FR3 (proposed) | Tests that the CSS tokens and the animator's tables derive from the same `tempo` | each role at the defaults and at each `tempo` bound; each `character`; an adaptation rule that sets `tempo` or `character` | a role off its measured value at `tempo` 1, a role that ignores `tempo`, or a spring whose bounce or duration changes with `character` fails | +| FR4a | Browser tests of each paired animation at each knob value | an element and gliding siblings, an element's entrance and exit, a sheet and its scrim, a menu's grow and content, a CSS fade with a spring | paired animations that end more than one frame apart fail | +| FR4 | Unit tests of each fixed item | finger tracking, momentum, rubber band, keyboard, press and hover clocks, loops, Reduce Motion cap; Spinner and Skeleton loops at each tempo | a fixed item that moves with `tempo` or `character`, or a cross-fade longer than 200 ms, fails | +| FR5 | Theme tests with the released configurations | a `MotionScaleConfig` theme, explicit tokens, an extending theme; an adaptation rule that sets `tempo` or `character` | changed output for an existing theme, or a knob that beats an explicit field, fails | +| FR6 | Server-render tests and import checks | a server render with each `reducedMotion`; importing the theme and motion modules; `'always'` with the person's setting off, across components that key on `prefers-reduced-motion`; a nested theme under an `'always'` theme | a DOM access at import or on the server, theme CSS emitted for `'system'`, a hydration change, or a component style, script reader, or `when: {motion}` rule that ignores the root mark under `'always'`, or a nested theme that restores motion under an ancestor's `'always'`, fails | +| FR7 | Docs review | each listed doc | a doc without the knobs, the fixed list, or the precedence fails | +| FR8 (proposed) | Theme unit tests over generated CSS, curve checks, and browser frame capture | `'fluid'` at each `tempo` bound and 1; each table row; a glide under a mouse and a finger; Skeleton's and Spinner's loops; Reduce Motion under a coarse and a fine pointer | a fluid curve that overshoots, leaves the travel, or ends or starts at speed where FR8 says rest, a shared token or base that differs from `'standard'`, a loop period or spring that changes at `'fluid'`, or travel under Reduce Motion fails | ## Decision log @@ -203,6 +246,44 @@ internal" for exactly these three knobs. Rejected: a public token per motion group. +### DEC-3 (proposed) — The fluid character is a token preset + +**Reference:** `spec:AST-108/DEC-3` +**Decider:** proposed, `2026-11-04`; pending owner review +**Basis:** owner direction, `2026-11-02`: a character presets the motion +tables, and the preset is named `fluid`. Owner direction, `2026-11-04`: +`fluid` is a fourth column of the token table, with its own entrance and move +curves and its own medium duration bases, all scaled by `tempo`; it is not +fitted to measured springs, and no option plays a platform-measured curve +for tap-started motion. + +A theme that wants unhurried, settling motion sets one knob. Its three +medium durations each take the next value of the released scale, so they +last about a third longer and stay on values the scale already has; quick +feedback keeps its fast durations. Its curves +have no overshoot and settle over a long tail. The move curve starts from +rest, since the element is already on screen; the entrance curve starts at +speed, since the element appears. Exits stay quiet, and fades and state +changes keep `--ease-standard`. This amends DEC-1's three character steps. + +Rejected: curves fitted to the measured springs. + +### DEC-4 (proposed) — A character sets only tokens; springs keep their bounce + +**Reference:** `spec:AST-108/DEC-4` +**Decider:** proposed, `2026-11-04`; pending owner review +**Basis:** owner direction, `2026-11-04`: gestures play the measured spring +roles scaled by `tempo`, with their bounce unchanged, and `character` +touches only tokens, for one coordinated scale. + +Every spring keeps its measured feel at every character, and `tempo` scales +the springs and the curve tokens by one factor, so one scale coordinates +them. +A character changes only what the curve tokens play. FR3 has no per-role +bounce offset or weights; this amends DEC-1 for `character`. + +Rejected: a gesture spring for each character. + ## Open questions - **OQ1 — Whether a theme's motion values get a public resolver.**