From 50e47ea46dcfc8b3af66355f3b3abf0688895177 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 05:13:40 -0800 Subject: [PATCH 1/5] feat(core): the animator's springs follow the theme's tempo A theme's `tempo` reached the CSS motion tokens but not the touch springs, so a slowed theme played its fades and its springs at two speeds. resolveMotion, the one door every touch spring and role duration passes through, now reads the tempo of the theme the element is in: the private property the theme writes beside its duration tokens. A spring keeps its damping ratio and takes tempo times as long, and a duration role scales. The layout glide resolves springs.settle through it at the area, under every pointer, as the CSS tokens already do. Finger tracking, ProgressBar's busy loop and curves stay as measured (spec:AST-108 FR4). The springs don't follow `character` yet; that part waits on its record. Switch's release settles on a curve it builds itself, so it now resolves its lift and settle springs and its minimum lift at the thumb, as its lift does. A theme layer whose durations come from a duration scale alone writes a tempo of 1, so a theme nested in one with a tempo keeps its springs at the pace of its own durations (FR5). Specs that set a tempo on the document now set it on the component, below the story's theme root. A `Core/Theme motion tempo` story shows three themes side by side for a feel check on a touch screen; it and a nested duration-scale story are the browser proofs' fixtures. --- .changeset/theme-tempo-springs.md | 8 + .../stories/ThemeMotionTempo.stories.tsx | 167 +++++++++++++ .../cli/assets/docs/author-a-theme.doc.mjs | 2 +- packages/cli/assets/docs/motion.doc.mjs | 2 +- packages/cli/assets/theme.template.ts | 4 +- .../ProgressBarMotion.a11y.chromium.spec.ts | 5 +- .../ProgressBarTouch.a11y.chromium.spec.ts | 5 +- .../core/src/Switch/useSwitchTouch.test.ts | 46 +++- packages/core/src/Switch/useSwitchTouch.ts | 18 +- packages/core/src/motion/README.md | 5 +- .../themeTempo.interaction.browser.spec.ts | 228 ++++++++++++++++++ .../core/src/motion/animatedLayout.test.ts | 15 ++ packages/core/src/motion/animatedLayout.ts | 17 +- packages/core/src/motion/animator.ts | 5 +- .../src/motion/resolveMotion.tempo.test.ts | 182 ++++++++++++++ .../core/src/motion/resolveMotion.test.ts | 17 +- packages/core/src/motion/resolveMotion.ts | 107 ++++++-- packages/core/src/motion/themeTempo.test.ts | 52 ++++ packages/core/src/motion/themeTempo.ts | 41 ++++ packages/core/src/theme/defineTheme.ts | 4 +- packages/core/src/theme/resolveThemeValues.ts | 15 +- packages/core/src/theme/themeMotion.test.ts | 46 ++++ packages/core/src/theme/themeMotion.ts | 12 +- 23 files changed, 944 insertions(+), 59 deletions(-) create mode 100644 .changeset/theme-tempo-springs.md create mode 100644 apps/storybook/stories/ThemeMotionTempo.stories.tsx create mode 100644 packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts create mode 100644 packages/core/src/motion/resolveMotion.tempo.test.ts create mode 100644 packages/core/src/motion/themeTempo.test.ts create mode 100644 packages/core/src/motion/themeTempo.ts diff --git a/.changeset/theme-tempo-springs.md b/.changeset/theme-tempo-springs.md new file mode 100644 index 0000000000000..a10d162370d59 --- /dev/null +++ b/.changeset/theme-tempo-springs.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] Motion: a theme's `tempo` now times the springs of touch motion as well as its CSS durations. Each spring keeps its shape and takes `tempo` times as long, and so does the animated-layout glide. Finger tracking and busy loops stay as measured. A theme with only a duration scale keeps its springs at tempo 1, even inside a theme with a tempo. The springs don't follow `character` yet. The motion doc says so. + +@vjeux diff --git a/apps/storybook/stories/ThemeMotionTempo.stories.tsx b/apps/storybook/stories/ThemeMotionTempo.stories.tsx new file mode 100644 index 0000000000000..1371301577418 --- /dev/null +++ b/apps/storybook/stories/ThemeMotionTempo.stories.tsx @@ -0,0 +1,167 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {useRef, useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Collapsible} from '@astryxdesign/core/Collapsible'; +import {useAnimatedLayout} from '@astryxdesign/core/hooks'; +import {ScrollableArea} from '@astryxdesign/core/ScrollableArea'; +import { + SegmentedControl, + SegmentedControlItem, +} from '@astryxdesign/core/SegmentedControl'; +import {Slider} from '@astryxdesign/core/Slider'; +import {Switch} from '@astryxdesign/core/Switch'; +import {Theme, defineTheme} from '@astryxdesign/core/theme'; + +const meta: Meta = { + title: 'Core/Theme motion tempo', + parameters: {layout: 'padded'}, +}; + +export default meta; + +type Story = StoryObj; + +const TEMPOS = [0.75, 1, 1.5] as const; + +const THEMES = TEMPOS.map(tempo => + defineTheme({ + name: `motion-tempo-${String(tempo).replace('.', '-')}`, + motion: {tempo}, + }), +); + +const NAMES: Record<(typeof TEMPOS)[number], string> = { + 0.75: 'Tempo 0.75, the quickest', + 1: "Tempo 1, Astryx's own", + 1.5: 'Tempo 1.5, the slowest', +}; + +const card = { + display: 'grid', + gap: 16, + padding: 12, + borderRadius: 12, + border: '1px solid var(--color-border)', + background: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + fontFamily: 'var(--font-family-body)', + fontSize: 14, +} as const; + +const title = { + margin: '0 0 6px', + fontSize: 12, + fontWeight: 600, + color: 'var(--color-text-secondary)', + fontFamily: 'var(--font-family-body)', +} as const; + +/** + * Details that open with a glide: the content after them glides on the tempo + * of the theme they are in. + */ +function Details({label}: {label: string}) { + const containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + return ( + + +

Ships in 2 business days.

+

Free returns for 30 days.

+
+

+ The content after it glides on the theme's tempo. +

+
+ ); +} + +/** + * One theme's controls: their touch springs and the Collapsible's glide play + * at the theme's tempo. + */ +function Row({index}: {index: number}) { + const tempo = TEMPOS[index]; + const [isOn, setIsOn] = useState(false); + const [volume, setVolume] = useState(40); + const [view, setView] = useState('day'); + return ( +
+
{NAMES[tempo]}
+ +
+ + + + + + + +
+
+
+
+ ); +} + +/** + * The same controls under three themes that set only `motion.tempo`. On a + * touch screen, try each row: toggle the switch, drag the slider, slide the + * segments and open the details. Every spring and the glide take the tempo + * times their own time. + */ +export const ThreeTempos: Story = { + name: 'Three tempos', + render: () => ( +
+ {TEMPOS.map((tempo, index) => ( + + ))} +
+ ), +}; + +/** A theme that sets only a duration scale, as an existing theme does. */ +const durationScaleTheme = defineTheme({ + name: 'motion-duration-scale', + motion: {fast: 130, medium: 310, ratio: 0.75}, +}); + +/** + * A theme with only a duration scale, nested in a theme at tempo 1.5. Its + * glide keeps the pace of its own durations, as on a page with no tempo. The + * glide beside it, in the outer theme, takes 1.5 times as long. + */ +export const NestedDurationScale: Story = { + name: 'A duration scale nested in a tempo', + render: () => ( +
+
+
No theme tempo
+
+
+
+
+ +
+
Tempo 1.5
+
+
+
+
+ +
+
A duration scale inside tempo 1.5
+
+
+
+
+
+
+
+ ), +}; diff --git a/packages/cli/assets/docs/author-a-theme.doc.mjs b/packages/cli/assets/docs/author-a-theme.doc.mjs index 1ff25c6e11538..43479a9454340 100644 --- a/packages/cli/assets/docs/author-a-theme.doc.mjs +++ b/packages/cli/assets/docs/author-a-theme.doc.mjs @@ -41,7 +41,7 @@ export const docs = { ['typography.scale', '--text-heading-*-size/weight/leading, --text-body-size/weight/leading', 'base (px), ratio'], ['typography.body/heading/code', '--font-family-body, --font-family-heading, --font-family-code', 'family, fallbacks?, url?, weight?'], ['radius', '--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat', 'base (px), multiplier (0–2)'], - ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (springs don\'t read them yet)', 'tempo? (0.75–1.5), character? (crisp|standard|expressive); and/or a duration scale: fast (ms), medium (ms), ratio, slow?, easing?. The scale wins over tempo for the durations it sets, and explicit tokens win over both. Fixed whatever the knobs say: drags and momentum, rubber band, keyboard, press onset/cancel/flash, hover intent, gesture thresholds, Spinner/Skeleton loops, the 200 ms Reduce Motion cap. See {@link generic:motion}.'], + ['motion', 'Every --duration-* token, --ease-entrance and --ease-move (tempo also times the touch springs)', '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}.'}, ], diff --git a/packages/cli/assets/docs/motion.doc.mjs b/packages/cli/assets/docs/motion.doc.mjs index 5c6ca59ceb099..128b5464159b0 100644 --- a/packages/cli/assets/docs/motion.doc.mjs +++ b/packages/cli/assets/docs/motion.doc.mjs @@ -150,7 +150,7 @@ const styles = stylex.create({ 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.", + text: "A theme sets how motion feels with two knobs in `defineTheme`'s `motion`. They drive the CSS motion tokens, and `tempo` also times the springs of touch motion; those springs don't follow `character` yet. Every theme follows each person's Reduce Motion setting; no theme loosens it.", }, { type: 'list', diff --git a/packages/cli/assets/theme.template.ts b/packages/cli/assets/theme.template.ts index d55fc91816e2a..58b771ced47d6 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -181,8 +181,8 @@ export const myTheme = defineTheme({ radius: {base: 4, multiplier: 1}, /** - * Motion. Two knobs set how its CSS feels (touch springs don't read them - * yet): + * Motion. Two knobs set how it feels. tempo also times the touch springs; + * character sets only the CSS curves for now: * 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 diff --git a/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts b/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts index 182f864a670a2..1b20cd7d642f0 100644 --- a/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts +++ b/packages/core/src/ProgressBar/__tests__/ProgressBarMotion.a11y.chromium.spec.ts @@ -705,7 +705,10 @@ test('a theme\u2019s tempo leaves the reduced-motion pulse unscaled', async ({ `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story`, ); await expect(page.getByRole('progressbar').first()).toBeVisible(); - await page.addStyleTag({content: ':root { --_astryx-motion-tempo: 1.5; }'}); + // On the bar itself: the story's theme root declares its own tempo. + await page.addStyleTag({ + content: '[role="progressbar"] { --_astryx-motion-tempo: 1.5; }', + }); const durations = await page.evaluate(() => Array.from(document.querySelectorAll('[role="progressbar"]')).map(track => Number( diff --git a/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts b/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts index 6bb95faa31be3..66b515f5e2093 100644 --- a/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts +++ b/packages/core/src/ProgressBar/__tests__/ProgressBarTouch.a11y.chromium.spec.ts @@ -186,7 +186,10 @@ test('a theme\u2019s tempo scales the move', async ({page}) => { // A theme with a tempo writes it beside its tokens. The move's duration is // the role times the share moved, times the tempo. await open(page, 10); - await page.addStyleTag({content: ':root { --_astryx-motion-tempo: 1.5; }'}); + // On the bar itself: the story's theme root declares its own tempo. + await page.addStyleTag({ + content: '[role="progressbar"] { --_astryx-motion-tempo: 1.5; }', + }); await changeAndPause(page, 90); const duration = await page.evaluate(() => { const fill = document.querySelector('[role="progressbar"]') diff --git a/packages/core/src/Switch/useSwitchTouch.test.ts b/packages/core/src/Switch/useSwitchTouch.test.ts index 63fd79d0771a5..208b3405aa1d4 100644 --- a/packages/core/src/Switch/useSwitchTouch.test.ts +++ b/packages/core/src/Switch/useSwitchTouch.test.ts @@ -5,8 +5,9 @@ * @input vitest (jsdom), useSwitchTouch.ts's controller, and a recording * stand-in for the compositor animator * @output Unit tests for what Switch's touch presentation asks the animator - * to do: a value change mid-fade swaps the colour layer on its running - * clock (a rebase), never from rest + * to do. A value change mid-fade swaps the colour layer on its running + * clock (a rebase), never from rest. A press's settle takes the theme's + * tempo, as its lift does * @position Testing; the controller's calls, without a browser. The motion * itself is proven in __tests__/Switch.interaction.browser.spec.ts and on * the iOS simulator @@ -17,6 +18,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import type * as AnimatorModule from '../motion/animator'; +import type {MotionCurve} from '../motion/animator'; interface Call { readonly name: string; @@ -236,3 +238,43 @@ describe('the colour layer when the value changes', () => { expect(fade.calls).toEqual([]); }); }); + +describe("the press's lift and settle at the theme's tempo", () => { + /** The release curve's length when the thumb's theme plays at `tempo`. */ + function settleSeconds(tempo: number): number { + const style = document.createElement('style'); + style.textContent = `.paced { --_astryx-motion-tempo: ${tempo}; }`; + document.head.append(style); + const {controller, track} = mount(); + const thumbElement = track.firstElementChild as HTMLElement; + thumbElement.className = 'paced'; + const thumb = animators.find( + animator => + animator.element === thumbElement && animator.pseudoElement == null, + ); + if (thumb == null) { + throw new Error('no thumb animator'); + } + // A press released at once: the settle waits out the minimum lift. + const press = controller as unknown as { + lift(time: number): void; + settle(time: number, touchRelease: boolean): void; + }; + press.lift(0); + press.settle(0, false); + const play = thumb.calls.find(call => call.name === 'play'); + const curve = (play?.args[0] as Record).scaleX; + document.body.innerHTML = ''; + document.head.innerHTML = ''; + animators.length = 0; + return curve.duration; + } + + it('waits the minimum lift and settles on springs that all take the tempo times as long', () => { + const atOne = settleSeconds(1); + // The minimum lift alone is 235 ms, so the curve is on screen. + expect(atOne).toBeGreaterThan(0.235); + expect(settleSeconds(1.5) / atOne).toBeCloseTo(1.5, 6); + expect(settleSeconds(0.75) / atOne).toBeCloseTo(0.75, 6); + }); +}); diff --git a/packages/core/src/Switch/useSwitchTouch.ts b/packages/core/src/Switch/useSwitchTouch.ts index 2422dc0f4735d..30faeacf0fb8a 100644 --- a/packages/core/src/Switch/useSwitchTouch.ts +++ b/packages/core/src/Switch/useSwitchTouch.ts @@ -30,6 +30,7 @@ import { import {solveSpring, toSpring, type SpringDescription} from '../motion/spring'; import {UIKIT_GESTURES} from '../motion/uikit/gestures'; import {registerSwitchTable, switchControl} from '../motion/uikit/switch'; +import {resolveMotion} from '../motion/resolveMotion'; import {useTouchPresentation} from '../utils/touchPresentation'; import {awaitBrowserClick, clickForLift} from '../utils/browserClickWait'; import {armClickGuard} from '../utils/clickGuard'; @@ -891,9 +892,16 @@ export class SwitchTouchController { return; } this.liftTime = null; - const lift = switchControl.lift; - const settle = switchControl.settle; - this.settleAt = settleTime(time, liftTime); + // The lift played these roles at the thumb's tempo, so the settle and the + // minimum lift take that tempo too. + const element = this.thumb?.element; + const lift = resolveMotion(element, switchControl.lift); + const settle = resolveMotion(element, switchControl.settle); + this.settleAt = settleTime( + time, + liftTime, + resolveMotion(element, switchControl, 'minimumLiftMs'), + ); const hold = (this.settleAt - time) / 1000; const play = ( animator: Animator | null, @@ -962,8 +970,8 @@ export class SwitchTouchController { rest: held ? keep : 1, holdSeconds: held ? 0 : hold, threshold: 1e-4, - lift: switchControl.lift, - settle: switchControl.settle, + lift: resolveMotion(thumb.element, switchControl.lift), + settle: resolveMotion(thumb.element, switchControl.settle), }), }); } diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 352a45bbd9c4f..cdc62aacc8529 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -57,8 +57,9 @@ names for their use (`springs.settle`, `timing.stateChangeMs`, `timing.curves.move`, …): a shared table lists them when it registers (`roles`), and every other value, a component's measured spring included, is a plain constant. The per-role tables stay internal (`spec:AST-094` DEC-15): a -role plays its table value, and nothing reads its CSS variable -(`--motion-springs-settle`), so no page or theme style sets a role. A table's +role plays its table value at the theme's tempo, and nothing reads the +role's own CSS variable (`--motion-springs-settle`), so no page or theme +style sets a role. A table's roles exist once its `registerTable()` has run: the animator registers the Reduce Motion fade's curve, and code that plays the shared springs calls `registerSpringsTable()` before its first motion. The animator resolves each diff --git a/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts new file mode 100644 index 0000000000000..1aa548d32af93 --- /dev/null +++ b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts @@ -0,0 +1,228 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeTempo.interaction.browser.spec.ts + * @input The motion source (animator, springs) bundled with esbuild onto a + * blank page, and the built Storybook's `Core/Theme motion tempo` story; + * Chromium and WebKit on the phone profile + * @output Proof that the springs follow the tempo of the theme they play + * in. A spring the animator plays inside a scope at tempo 1.5 is + * inherited through a nested element. At 1.5 × t it draws what the same + * spring draws at t outside the scope. Finger tracking draws the same in + * both. Under three real themes that set only `motion.tempo`, the + * animated-layout glide lasts 0.75, 1 and 1.5 times as long, with a + * finger or a mouse. A theme with only a duration scale, nested in one at + * tempo 1.5, glides at its own pace + * @position Browser proof for themeTempo.ts and resolveMotion.ts, run by + * `pnpm test:interaction`; the unit proofs are resolveMotion.test.ts + */ + +import {fileURLToPath} from 'node:url'; +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; +import {build} from 'esbuild'; +import type * as MotionBundle from './motionBundle'; + +declare const Motion: typeof MotionBundle; + +let script = ''; +let storybook: StaticServer | undefined; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [fileURLToPath(new URL('./motionBundle.ts', import.meta.url))], + bundle: true, + write: false, + format: 'iife', + globalName: 'Motion', + target: 'es2022', + }); + script = result.outputFiles[0].text; + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); + +test.afterAll(async () => { + await storybook?.close(); +}); + +const TRAVEL = 300; + +// A real navigation (not setContent): the page keeps its own window. The +// scope writes the private property a theme that sets `tempo` writes. +async function openScopes(page: Page) { + const html = ` + +
+
+ +`; + await page.route('http://tempo.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://tempo.test/'); +} + +/** + * Plays `role` on both boxes and reads each one's travel, paused at each of + * `times` (ms), the slowed box at its time times `scale`. + */ +async function travels( + page: Page, + role: 'settle' | 'fingerTracking', + times: number[], + scale: number, +): Promise<{base: number; slowed: number}[]> { + return page.evaluate( + async ({role, times, scale, travel}) => { + const boxes = ['base', 'slowed'].map( + id => document.getElementById(id) as HTMLElement, + ); + const handles = boxes.map(box => + Motion.createMotionAnimator(box).animate( + {x: travel}, + {spring: Motion.springs[role]}, + ), + ); + // From rest, the animations start in the frame after the call. + await Promise.all(handles.map(async handle => handle.started)); + const [base, slowed] = boxes.map(box => { + const [animation] = box.getAnimations(); + animation.pause(); + return animation; + }); + const xOf = (box: HTMLElement) => + Number.parseFloat(getComputedStyle(box).translate || '0'); + return times.map(t => { + base.currentTime = t; + slowed.currentTime = t * scale; + return {base: xOf(boxes[0]), slowed: xOf(boxes[1])}; + }); + }, + {role, times, scale, travel: TRAVEL}, + ); +} + +test.describe('the springs follow the theme tempo (spec:AST-108 FR3)', () => { + test("a spring inside a tempo 1.5 scope draws at 1.5 × t what it draws at t outside it", async ({ + page, + }) => { + await openScopes(page); + const read = await travels(page, 'settle', [40, 120, 240, 400], 1.5); + // The boxes are on screen and moving, so the comparison is not vacuous. + expect(read[0].base).toBeGreaterThan(5); + expect(read[0].base).toBeLessThan(TRAVEL - 5); + for (const {base, slowed} of read) { + expect(Math.abs(slowed - base)).toBeLessThan(1.5); + } + }); + + test('finger tracking plays as measured inside the scope (FR4)', async ({ + page, + }) => { + await openScopes(page); + const read = await travels(page, 'fingerTracking', [20, 60, 120], 1); + expect(read[0].base).toBeGreaterThan(5); + for (const {base, slowed} of read) { + expect(Math.abs(slowed - base)).toBeLessThan(1.5); + } + }); +}); + +const STORY = 'core-theme-motion-tempo--three-tempos'; +const NESTED_STORY = 'core-theme-motion-tempo--nested-duration-scale'; + +async function openStory(page: Page, story: string, rowCount: number) { + await page.goto( + `${storybook?.origin ?? ''}/iframe.html?id=${story}&viewMode=story`, + ); + const rows = page.locator('section[data-tempo], section[data-row]'); + await expect(rows).toHaveCount(rowCount); + // Every row is on screen, so no ratio below compares empty rows. + for (const height of await rows.evaluateAll(sections => + sections.map(section => section.getBoundingClientRect().height), + )) { + expect(height).toBeGreaterThan(0); + } +} + +/** The glide's length for the content after the Collapsible in `row`. */ +async function glideMs(page: Page, row: string): Promise { + const section = page.locator(row); + await section.getByRole('button', {name: 'Shipping details'}).click(); + const after = section.locator('[data-after-collapsible]'); + await expect + .poll(async () => + after.evaluate(element => element.getAnimations().length), + ) + .toBeGreaterThan(0); + return after.evaluate(element => { + const glide = element + .getAnimations() + .find(animation => + (animation.effect as KeyframeEffect) + .getKeyframes() + .some(frame => typeof frame.translate === 'string'), + ); + return Number(glide?.effect?.getTiming().duration); + }); +} + +/** The glide's length in each tempo row, against the tempo 1 row. */ +async function tempoRatios(page: Page) { + const atOne = await glideMs(page, 'section[data-tempo="1"]'); + expect(atOne).toBeGreaterThan(300); + return { + quick: (await glideMs(page, 'section[data-tempo="0.75"]')) / atOne, + slow: (await glideMs(page, 'section[data-tempo="1.5"]')) / atOne, + }; +} + +test('the glide under three themes lasts 0.75, 1 and 1.5 times as long', async ({ + page, +}) => { + await openStory(page, STORY, 3); + const {quick, slow} = await tempoRatios(page); + expect(quick).toBeCloseTo(0.75, 2); + expect(slow).toBeCloseTo(1.5, 2); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the glide under three themes lasts 0.75, 1 and 1.5 times as long', async ({ + page, + }) => { + await openStory(page, STORY, 3); + // A fine pointer in this context, before anything is checked. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const {quick, slow} = await tempoRatios(page); + expect(quick).toBeCloseTo(0.75, 2); + expect(slow).toBeCloseTo(1.5, 2); + }); +}); + +test('a duration scale nested in a tempo theme glides at its own pace', async ({ + page, +}) => { + await openStory(page, NESTED_STORY, 3); + const plain = await glideMs(page, 'section[data-row="plain"]'); + expect(plain).toBeGreaterThan(300); + expect( + (await glideMs(page, 'section[data-row="outer"]')) / plain, + ).toBeCloseTo(1.5, 2); + expect( + (await glideMs(page, 'section[data-row="nested"]')) / plain, + ).toBeCloseTo(1, 2); +}); diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 98f61d34e473f..4f2fc781c9086 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -366,6 +366,21 @@ describe('one shared motion (spec:AST-105 FR4)', () => { expect(scoped?.options.duration).toBe(shared?.options.duration); }); + it("plays the shared spring at the tempo of the area's theme (spec:AST-108 FR3)", async () => { + await changeOnce(); + const atOne = played.find(p => (p.element as HTMLElement).id === 'a'); + played = []; + document.body.innerHTML = ''; + // The private property a theme that sets `tempo` writes. + await changeOnce('--_astryx-motion-tempo: 1.5'); + const follower = played.find(p => (p.element as HTMLElement).id === 'a'); + const own = played.find(p => (p.element as HTMLElement).id === 'changed'); + const ratio = + Number(follower?.options.duration) / Number(atOne?.options.duration); + expect(Math.abs(ratio - 1.5)).toBeLessThan(0.01); + expect(own?.options.duration).toBe(follower?.options.duration); + }); + it('cross-fades with no travel under Reduce Motion on a coarse pointer', async () => { media['(prefers-reduced-motion: reduce)'] = true; media['(pointer: coarse)'] = true; diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 5e2dccb25e43d..903f8a3f75a89 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -2,9 +2,10 @@ /** * @file animatedLayout.ts - * @input The DOM (layout rectangles, computed style), Web Animations, the - * internal `springs.settle` motion role (motion/roles.ts), the Reduce Motion - * cross-fade clock (uikit/timing.ts) and the block measure (frameBlock.ts) + * @input The DOM (layout rectangles, computed style) and Web Animations. The + * internal `springs.settle` motion role at the area's tempo + * (resolveMotion.ts), the Reduce Motion cross-fade clock (uikit/timing.ts) + * and the block measure (frameBlock.ts) * @output Exports `createAnimatedLayout`, the opaque `AnimatedLayout` handle, * `reportLayoutChange`, `playLayoutChange` and `layoutStartFor`. It also * exports the exit hold (`holdLayoutExit`, `releaseLayoutExit`), the @@ -81,6 +82,7 @@ */ import {LATE_START_MS} from './frameBlock'; +import {resolveMotion} from './resolveMotion'; import {motionRole} from './roles'; import { inertCopy, @@ -137,12 +139,13 @@ function matches(element: Element, query: string): boolean { /** * `springs.settle` from the internal role registry: one shared value for the * glide, the changed element and the indicator. Motion roles are internal - * (`spec:AST-094` DEC-11): no theme, scope or custom property changes it. + * (`spec:AST-094` DEC-11): no page style sets the role. The glide plays it + * at the tempo of the theme that `area` is in. */ -function settleSpring(): SpringDescription { +function settleSpring(area: Element): SpringDescription { registerSpringsTable(); const value = motionRole(ROLE_ID)?.value; - return isSpring(value) ? value : SETTLE_SPRING; + return resolveMotion(area, isSpring(value) ? value : SETTLE_SPRING); } function springProgress(description: SpringDescription): SpringProgress { @@ -173,7 +176,7 @@ function layoutMotion(area: Element): LayoutMotion { easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, }; } - return {kind: 'spring', progress: springProgress(settleSpring())}; + return {kind: 'spring', progress: springProgress(settleSpring(area))}; } /** Linear keyframes over `durationMs`, one per frame, from `frame(t ms)`. */ diff --git a/packages/core/src/motion/animator.ts b/packages/core/src/motion/animator.ts index d9af6ca317e06..ea3c3aacc6387 100644 --- a/packages/core/src/motion/animator.ts +++ b/packages/core/src/motion/animator.ts @@ -157,7 +157,8 @@ * once `registerSpringsTable()` has run, the Reduce Motion fade's curve, * which the animator registers alone). The per-role tables stay internal * (`spec:AST-094` DEC-15), so a role plays its table value: nothing reads a - * CSS variable or the element's style for it. A spring written inline at the call + * role's own CSS variable. Its time scales by the tempo of the element's + * theme. A spring written inline at the call * site, and a table never registered, play as given; the fade's length is not * a role. * @@ -2729,7 +2730,7 @@ export function createMotionAnimator( if (from === to && velocity === 0) { return null; } - // The motion role it plays: its table value (DEC-15). + // The motion role it plays, at the tempo of the theme it is in. const resolved = resolveMotion(target, spring); const curve = solveSpring(toSpring(resolved), {from, to, velocity}); const duration = Math.min( diff --git a/packages/core/src/motion/resolveMotion.tempo.test.ts b/packages/core/src/motion/resolveMotion.tempo.test.ts new file mode 100644 index 0000000000000..0a0e24d372aee --- /dev/null +++ b/packages/core/src/motion/resolveMotion.tempo.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file resolveMotion.tempo.test.ts + * @input resolveMotion.ts, the shared UIKit tables, a stubbed computed style + * that writes a theme's tempo + * @output Proof that a role plays its table value at the theme's tempo. A + * spring keeps its shape and takes tempo times as long in each of its + * forms. A duration scales. A curve, a part that follows a finger and a + * busy loop stay as measured. A role resolves to one object per tempo and + * is never scaled twice. The animator plays the slowed spring + * @position Testing for /packages/core/src/motion/resolveMotion.ts (the + * browser proof: themeTempo.interaction.browser.spec.ts) + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {createMotionAnimator} from './animator'; +import {resolveMotion} from './resolveMotion'; +import {solveSpring, toSpring} from './spring'; +import { + progressBar, + registerProgressBarTable, + registerSpringsTable, + registerTimingTable, + springs, + switchControl, + timing, +} from './uikit'; +import {registerSwitchTable} from './uikit/switch'; + +// Like a touch module: register the tables these motions resolve from. +registerSpringsTable(); +registerTimingTable(); +registerSwitchTable(); +registerProgressBarTable(); + +const TEMPO_PROPERTY = '--_astryx-motion-tempo'; + +/** The properties read since the last reset, in order. */ +let reads: string[]; +/** The tempo the stubbed theme writes; empty where no theme sets one. */ +let tempo: string; + +beforeEach(() => { + reads = []; + tempo = ''; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + () => + ({ + getPropertyValue: (name: string) => { + reads.push(name); + return name === TEMPO_PROPERTY ? tempo : ''; + }, + }) as unknown as CSSStyleDeclaration, + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +function element(): HTMLElement { + const child = document.createElement('div'); + document.body.append(child); + return child; +} + +describe("resolveMotion at the theme's tempo (spec:AST-108 FR3)", () => { + it('stretches a spring role in each of its forms: the same shape, tempo times as long', () => { + tempo = '1.5'; + const target = element(); + // A duration and bounce, a response and damping ratio, and a mass, + // stiffness and damping. + for (const role of [springs.settle, springs.menuOpen, springs.viewChange]) { + const base = solveSpring(toSpring(role), {from: 0, to: 1}); + const slowed = solveSpring(toSpring(resolveMotion(target, role)), { + from: 0, + to: 1, + }); + expect(slowed.settleTime(0.001)).toBeCloseTo( + base.settleTime(0.001) * 1.5, + 6, + ); + for (const seconds of [0.05, 0.15, 0.3]) { + expect(slowed.value(seconds * 1.5)).toBeCloseTo( + base.value(seconds), + 6, + ); + } + } + expect(reads.every(name => name === TEMPO_PROPERTY)).toBe(true); + }); + + it('scales a duration role, and plays a curve role as measured without a style read', () => { + const target = element(); + tempo = '1.5'; + expect(resolveMotion(target, timing, 'stateChangeMs')).toBe( + timing.stateChangeMs * 1.5, + ); + tempo = '0.75'; + expect(resolveMotion(target, timing, 'stateChangeMs')).toBe( + timing.stateChangeMs * 0.75, + ); + reads = []; + expect(resolveMotion(target, timing.curves.move)).toBe(timing.curves.move); + expect(reads).toEqual([]); + }); + + it('leaves the parts that follow a finger and the busy loop as measured (FR4)', () => { + tempo = '1.5'; + const target = element(); + expect(resolveMotion(target, springs.fingerTracking)).toBe( + springs.fingerTracking, + ); + expect(resolveMotion(target, switchControl.tracking)).toBe( + switchControl.tracking, + ); + expect(resolveMotion(target, progressBar, 'reducedMotionPulseMs')).toBe( + progressBar.reducedMotionPulseMs, + ); + // The same tables' other roles follow the tempo. + expect(resolveMotion(target, switchControl.settle)).not.toBe( + switchControl.settle, + ); + expect(resolveMotion(target, progressBar, 'fillTrackMs')).toBe( + progressBar.fillTrackMs * 1.5, + ); + }); + + it('is the table value itself at tempo 1, where no theme sets a tempo, and without an element', () => { + const target = element(); + expect(resolveMotion(target, springs.settle)).toBe(springs.settle); + tempo = '1'; + expect(resolveMotion(target, springs.settle)).toBe(springs.settle); + expect(resolveMotion(target, timing, 'stateChangeMs')).toBe( + timing.stateChangeMs, + ); + tempo = '1.5'; + expect(resolveMotion(null, springs.settle)).toBe(springs.settle); + }); + + it('gives one object per role and tempo, and does not scale a resolved spring again', () => { + tempo = '1.5'; + const target = element(); + const once = resolveMotion(target, springs.settle); + expect(once).not.toBe(springs.settle); + expect(resolveMotion(target, springs.settle)).toBe(once); + // A component resolves a spring and hands it to the animator, which + // resolves it again. + expect(resolveMotion(target, once)).toBe(once); + }); + + it('leaves values that are not roles alone', () => { + tempo = '1.5'; + const inline = {response: 0.4, dampingRatio: 0.9}; + expect(resolveMotion(document.body, inline)).toBe(inline); + expect(resolveMotion(document.body, springs.sheetPresent)).toBe( + springs.sheetPresent, + ); + expect(resolveMotion(document.body, timing.press, 'scrollBrakeMs')).toBe( + timing.press.scrollBrakeMs, + ); + }); +}); + +describe('the animator', () => { + it("plays a role's spring at the theme's tempo", () => { + tempo = '1.5'; + const animator = createMotionAnimator(element(), {now: () => 0}); + animator.set({y: 300}); + animator.animate({y: 0}, {spring: springs.settle}, {at: 0}); + const table = solveSpring(toSpring(springs.settle), {from: 300, to: 0}); + for (const ms of [75, 225, 600]) { + expect(animator.get('y', ms)).toBeCloseTo( + table.value(ms / 1000 / 1.5), + 3, + ); + } + animator.dispose(); + }); +}); diff --git a/packages/core/src/motion/resolveMotion.test.ts b/packages/core/src/motion/resolveMotion.test.ts index cf5a3067619ce..1cb29828d18f9 100644 --- a/packages/core/src/motion/resolveMotion.test.ts +++ b/packages/core/src/motion/resolveMotion.test.ts @@ -3,10 +3,10 @@ /** * @file resolveMotion.test.ts * @input resolveMotion.ts, the shared UIKit tables, a stubbed computed style - * @output Proof that, with the per-role tables internal (spec:AST-094 - * DEC-15), a role plays its table value whatever a scope's CSS variable - * says, reads no style, and leaves values that are not roles alone; and - * that the animator plays the table's spring + * @output Proof that a role plays its table value whatever a scope's CSS + * variable says (spec:AST-094 DEC-15). No role's variable is read, and + * values that are not roles are left alone. The animator plays the table's + * spring. The theme's tempo has its own file: resolveMotion.tempo.test.ts * @position Testing for /packages/core/src/motion/resolveMotion.ts (the * browser proof: animator.interaction.browser.spec.ts) */ @@ -35,8 +35,11 @@ beforeEach(() => { vi.spyOn(window, 'getComputedStyle').mockImplementation( () => ({ - getPropertyValue: () => { - reads += 1; + getPropertyValue: (name: string) => { + // The theme's tempo is the one property a role may read. + if (name !== '--_astryx-motion-tempo') { + reads += 1; + } return ''; }, }) as unknown as CSSStyleDeclaration, @@ -74,7 +77,7 @@ describe('resolveMotion while the per-role tables stay internal (DEC-15)', () => timing.stateChangeMs, ); expect(resolveMotion(null, springs.settle)).toBe(springs.settle); - // No style is read to resolve a role. + // No role's variable is read to resolve a role. expect(reads).toBe(0); }); diff --git a/packages/core/src/motion/resolveMotion.ts b/packages/core/src/motion/resolveMotion.ts index e14d509d64988..8e6af5fcb873c 100644 --- a/packages/core/src/motion/resolveMotion.ts +++ b/packages/core/src/motion/resolveMotion.ts @@ -3,32 +3,87 @@ /** * @file resolveMotion.ts * @input A motion role: a spring or curve of a registered motion table, or a - * table group and the key of one of its values - * @output resolveMotion: the role's value + * table group and the key of one of its values. The element the motion + * plays on, for its theme's tempo (themeTempo.ts) + * @output resolveMotion: the role's value at the theme's tempo * @position Motion foundation; the animator resolves every spring and curve * it is handed with it, and components resolve the durations they time * with it * * Every touch motion passes through here, so the role a value plays is named - * in one place. A role plays its table value: the per-role tables stay - * internal (`spec:AST-094` DEC-15), so nothing reads a role's CSS variable - * and no page or theme style sets a role. Nothing here reads style or - * layout, listens or observes, imports a table, or runs on import or on the - * server. + * in one place. The per-role tables stay internal (`spec:AST-094` DEC-15). + * Nothing reads a role's own CSS variable, and no page or theme style sets a + * role. Nothing here reads layout, listens or observes, imports a table, or + * runs on import or on the server. + * + * The value plays at the tempo of the theme the element is in. The tempo + * stretches a spring and scales a duration. A curve, a part that follows a + * finger, and a busy loop play as measured. At tempo 1 every role plays + * exactly its table value. * * SYNC: When modified, update: * - /packages/core/src/motion/resolveMotion.test.ts * - /packages/core/src/motion/animator.ts (resolves each role it plays) */ +import {motionRoleIn, motionRoleOf} from './roles'; +import {isSpring} from './roleValue'; import type {MotionCurvePoints} from './roles'; import type {SpringDescription} from './spring'; +import {themeTempo} from './themeTempo'; + +/** + * The roles the tempo leaves as measured: the parts that follow a finger, + * and ProgressBar's busy loop. + */ +const FIXED_ROLES: ReadonlySet = new Set([ + 'springs.fingerTracking', + 'switchControl.tracking', + 'progress.reducedMotionPulseMs', +]); + +/** Each role spring at each tempo, so a role resolves to one object per tempo. */ +const springsAtTempo = new WeakMap>(); /** - * A motion role's value: its table value, as the per-role tables stay - * internal (`spec:AST-094` DEC-15). Hand it a spring or curve of a motion - * table, or a table group and a key (the way to name a duration, which is a - * bare number): + * `spring` at `tempo`. In each of its forms, the undamped period grows by the + * tempo and the damping ratio stays. So the motion keeps its shape and takes + * `tempo` times as long. + */ +function springAtTempo( + spring: SpringDescription, + tempo: number, +): SpringDescription { + let byTempo = springsAtTempo.get(spring); + if (byTempo == null) { + byTempo = new Map(); + springsAtTempo.set(spring, byTempo); + } + let scaled = byTempo.get(tempo); + if (scaled == null) { + if ('response' in spring) { + scaled = { + response: spring.response * tempo, + dampingRatio: spring.dampingRatio, + }; + } else if ('duration' in spring) { + scaled = {duration: spring.duration * tempo, bounce: spring.bounce}; + } else { + scaled = { + ...(spring.mass == null ? null : {mass: spring.mass}), + stiffness: spring.stiffness / (tempo * tempo), + damping: spring.damping / tempo, + }; + } + byTempo.set(tempo, scaled); + } + return scaled; +} + +/** + * A motion role's value at the tempo of the theme `element` is in. Hand it a + * spring or curve of a motion table, or a table group and a key (the way to + * name a duration, which is a bare number): * * @example * ``` @@ -36,9 +91,9 @@ import type {SpringDescription} from './spring'; * const fadeMs = resolveMotion(row, timing, 'stateChangeMs'); * ``` * - * The element names where the motion plays; anything that is not a role - * comes back unchanged too, so a spring - * written inline at a call site keeps working. + * The element names where the motion plays. Anything that is not a role + * comes back unchanged. So a spring written inline at a call site keeps + * working, and a resolved spring is not scaled again. */ export function resolveMotion( element: Element | null | undefined, @@ -58,11 +113,27 @@ export function resolveMotion( ? MotionCurvePoints : G[K] | SpringDescription; export function resolveMotion( - _element: Element | null | undefined, + element: Element | null | undefined, leafOrGroup: object, key?: string, ): unknown { - return key === undefined - ? leafOrGroup - : (leafOrGroup as Record)[key]; + const value = + key === undefined + ? leafOrGroup + : (leafOrGroup as Record)[key]; + const role = + key === undefined + ? motionRoleOf(leafOrGroup) + : motionRoleIn(leafOrGroup, key); + if (role == null || role.kind === 'curve' || FIXED_ROLES.has(role.id)) { + return value; + } + const tempo = themeTempo(element); + if (tempo === 1) { + return value; + } + if (typeof value === 'number') { + return value * tempo; + } + return isSpring(value) ? springAtTempo(value, tempo) : value; } diff --git a/packages/core/src/motion/themeTempo.test.ts b/packages/core/src/motion/themeTempo.test.ts new file mode 100644 index 0000000000000..2ebd3186f4e7b --- /dev/null +++ b/packages/core/src/motion/themeTempo.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeTempo.test.ts + * @input themeTempo.ts, jsdom's computed style + * @output Proof that the tempo is the one the element's theme writes, and 1 + * without one or for a value that is not a positive number + * @position Testing for /packages/core/src/motion/themeTempo.ts (the browser + * proof, a nested theme included: themeTempo.interaction.browser.spec.ts) + */ + +import {afterEach, describe, expect, it} from 'vitest'; +import {themeTempo} from './themeTempo'; + +afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; +}); + +/** An element in a theme scope whose rule writes `value` as its tempo. */ +function inTempo(value: string): Element { + const style = document.createElement('style'); + style.textContent = `[data-tempo-scope] { --_astryx-motion-tempo: ${value}; }`; + document.head.append(style); + const element = document.createElement('div'); + element.setAttribute('data-tempo-scope', ''); + document.body.append(element); + return element; +} + +describe('themeTempo', () => { + it("is the tempo the element's theme writes", () => { + expect(themeTempo(inTempo('1.5'))).toBe(1.5); + document.head.innerHTML = ''; + expect(themeTempo(inTempo('0.85'))).toBe(0.85); + }); + + it('is 1 without an element, or where no theme sets a tempo', () => { + expect(themeTempo(null)).toBe(1); + expect(themeTempo(undefined)).toBe(1); + const element = document.createElement('div'); + document.body.append(element); + expect(themeTempo(element)).toBe(1); + }); + + it('reads a value that is not a positive number as 1', () => { + for (const value of ['0', '-1', 'fast']) { + document.head.innerHTML = ''; + expect(themeTempo(inTempo(value))).toBe(1); + } + }); +}); diff --git a/packages/core/src/motion/themeTempo.ts b/packages/core/src/motion/themeTempo.ts new file mode 100644 index 0000000000000..936ac78297c71 --- /dev/null +++ b/packages/core/src/motion/themeTempo.ts @@ -0,0 +1,41 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file themeTempo.ts + * @input An element on the page + * @output `themeTempo(element)`: the tempo of the theme the element is in + * @position Motion foundation; resolveMotion.ts scales a motion role by it + * + * A theme that sets `tempo` writes it, clamped, to the private custom + * property `--_astryx-motion-tempo` beside its duration tokens. An + * adaptation rule writes it where its condition holds, and a nested theme + * that sets no tempo inherits it. A layer whose durations come from a + * duration scale writes a tempo of 1, so its springs keep its own pace. One + * read of a custom property forces no layout. + * Nothing here imports a theme module, runs on import or runs on the server. + * + * SYNC: When modified, update: + * - /packages/core/src/motion/themeTempo.test.ts + * - /packages/core/src/theme/themeMotion.ts (writes the property) + */ + +/** The property a theme that sets `tempo` writes (theme/themeMotion.ts). */ +const TEMPO_PROPERTY = '--_astryx-motion-tempo'; + +/** + * The tempo motion on `element` plays at: the one its theme or an adaptation + * rule sets, else 1. Without an element in a document, or with a value that + * is not a positive number, it is 1. + */ +export function themeTempo(element: Element | null | undefined): number { + const view = element?.ownerDocument?.defaultView; + if (element == null || view == null) { + return 1; + } + const text = view + .getComputedStyle(element) + .getPropertyValue(TEMPO_PROPERTY) + .trim(); + const tempo = text === '' ? Number.NaN : Number(text); + return Number.isFinite(tempo) && tempo > 0 ? tempo : 1; +} diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index 3cd529ed3e63c..f5e8db526dadc 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -293,8 +293,8 @@ export interface DefineThemeInput< /** * Motion configuration: two knobs (spec:AST-108), a motion scale, or both. * - * - `tempo` (default 1, clamped to 0.75–1.5) scales every motion duration - * token; touch motion's springs don't read it yet. + * - `tempo` (default 1, clamped to 0.75–1.5) scales every motion duration: + * the duration tokens and touch motion's springs. * - `character` (`'crisp'`, `'standard'` or `'expressive'`) sets the * entrance and move curves; exits and fades never overshoot. * diff --git a/packages/core/src/theme/resolveThemeValues.ts b/packages/core/src/theme/resolveThemeValues.ts index 6185b9eb2a285..383297302e040 100644 --- a/packages/core/src/theme/resolveThemeValues.ts +++ b/packages/core/src/theme/resolveThemeValues.ts @@ -33,6 +33,7 @@ import { } from './expandTypeScale'; import { expandThemeMotion, + MOTION_TEMPO_PROPERTY, preTempoName, type MotionCharacter, type ThemeMotionConfig, @@ -315,16 +316,24 @@ export function resolveThemeValues( // 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. + // own, they are its durations. Such a layer also writes a tempo of 1, so + // its springs keep the pace of its durations inside a theme with a tempo. if (input.motion) { const motion = expandThemeMotion(input.motion); Object.assign(tokens, motion); + let writesDurations = false; for (const [name, value] of Object.entries(motion)) { const preTempo = preTempoName(name); - if (preTempo !== name && !(preTempo in motion)) { - tokens[preTempo] = value; + if (preTempo !== name) { + writesDurations = true; + if (!(preTempo in motion)) { + tokens[preTempo] = value; + } } } + if (writesDurations && !(MOTION_TEMPO_PROPERTY in motion)) { + tokens[MOTION_TEMPO_PROPERTY] = '1'; + } } // 5. Font family tokens diff --git a/packages/core/src/theme/themeMotion.test.ts b/packages/core/src/theme/themeMotion.test.ts index e4ff939df13d1..cbda56b8accbe 100644 --- a/packages/core/src/theme/themeMotion.test.ts +++ b/packages/core/src/theme/themeMotion.test.ts @@ -396,6 +396,52 @@ describe('a layer\u2019s own durations reach the busy loops under an enclosing t ).component; expect(css).toContain('--_astryx-pre-tempo-duration-medium: 300ms'); }); + + it('a theme whose durations come from a duration scale alone writes a tempo of 1, so its springs keep its pace inside a theme with one', () => { + const scaled = defineTheme({ + name: 'ScaledPace', + motion: {fast: 100, medium: 250, ratio: 0.75}, + }); + expect(scaled.tokens['--_astryx-motion-tempo']).toBe('1'); + // A child keeps its base's tempo beside a scale of its own. + const child = defineTheme({ + name: 'ScaledPaceChild', + extends: defineTheme({name: 'PacedBase', motion: {tempo: 1.5}}), + motion: {fast: 100, medium: 250, ratio: 0.75}, + }); + expect(child.tokens['--_astryx-motion-tempo']).toBe('1.5'); + // A knob that writes no duration, and one explicit duration, leave the + // enclosing tempo in place. + for (const theme of [ + defineTheme({name: 'CrispPace', motion: {character: 'crisp'}}), + defineTheme({name: 'ExplicitPace', tokens: {'--duration-slow': '800ms'}}), + ]) { + expect(theme.tokens['--_astryx-motion-tempo']).toBeUndefined(); + } + }); + + it('an adaptation rule whose durations come from a duration scale writes the tempo they play at', () => { + const ruleCss = (name: string, tempo?: number) => + generateAdaptationCSS( + defineTheme({ + name, + ...(tempo == null ? {} : {motion: {tempo}}), + adaptations: { + rules: [ + { + when: {pointer: 'coarse'}, + value: {motion: {fast: 100, medium: 250, ratio: 0.75}}, + }, + ], + }, + }), + ).component; + expect(ruleCss('AdaptiveScale')).toContain('--_astryx-motion-tempo: 1;'); + // Over a theme with a tempo, the rule keeps the theme's tempo. + expect(ruleCss('AdaptiveScaleTempo', 1.5)).toContain( + '--_astryx-motion-tempo: 1.5;', + ); + }); }); describe('a child of an installed built theme (spec:AST-108 FR5, INV15)', () => { diff --git a/packages/core/src/theme/themeMotion.ts b/packages/core/src/theme/themeMotion.ts index 86a7db7a636d1..86fd0878bd494 100644 --- a/packages/core/src/theme/themeMotion.ts +++ b/packages/core/src/theme/themeMotion.ts @@ -13,8 +13,9 @@ * `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. + * The animator's springs follow `tempo` (motion/themeTempo.ts), but not + * `character` 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 @@ -82,9 +83,10 @@ function msOf(value: string): number { /** * The private custom property a theme with a `tempo` writes beside its - * duration tokens, for a style that scales a clock of its own by it - * (ProgressBar's move). A press release needs none: it plays - * `--duration-exit`, which already follows the tempo. + * duration tokens. A layer whose durations come from a duration scale alone + * writes 1. The animator's springs read it (motion/themeTempo.ts), and so does + * a style that scales a clock of its own (ProgressBar's move). A press release + * needs none: it plays `--duration-exit`, which already follows the tempo. */ export const MOTION_TEMPO_PROPERTY = '--_astryx-motion-tempo'; From 21540f1fe75a42fc30f2dbe18e5c51fa9aa767bf Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 19:01:31 -0800 Subject: [PATCH 2/5] chore: re-trigger the preview deploy From 9e704d9ad6616265450c3f9863f4f8e75a78c9d6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 05:26:40 -0800 Subject: [PATCH 3/5] test(core): the tempo tests call the animator by its new name Main renamed createMotionAnimator to createMotionEngine. --- .../motion/__tests__/themeTempo.interaction.browser.spec.ts | 2 +- packages/core/src/motion/resolveMotion.tempo.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts index 1aa548d32af93..e4d7b5887a72c 100644 --- a/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts @@ -88,7 +88,7 @@ async function travels( id => document.getElementById(id) as HTMLElement, ); const handles = boxes.map(box => - Motion.createMotionAnimator(box).animate( + Motion.createMotionEngine(box).animate( {x: travel}, {spring: Motion.springs[role]}, ), diff --git a/packages/core/src/motion/resolveMotion.tempo.test.ts b/packages/core/src/motion/resolveMotion.tempo.test.ts index 0a0e24d372aee..1ae54a907c282 100644 --- a/packages/core/src/motion/resolveMotion.tempo.test.ts +++ b/packages/core/src/motion/resolveMotion.tempo.test.ts @@ -14,7 +14,7 @@ */ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {createMotionAnimator} from './animator'; +import {createMotionEngine} from './animator'; import {resolveMotion} from './resolveMotion'; import {solveSpring, toSpring} from './spring'; import { @@ -167,7 +167,7 @@ describe("resolveMotion at the theme's tempo (spec:AST-108 FR3)", () => { describe('the animator', () => { it("plays a role's spring at the theme's tempo", () => { tempo = '1.5'; - const animator = createMotionAnimator(element(), {now: () => 0}); + const animator = createMotionEngine(element(), {now: () => 0}); animator.set({y: 300}); animator.animate({y: 0}, {spring: springs.settle}, {at: 0}); const table = solveSpring(toSpring(springs.settle), {from: 300, to: 0}); From e2706764d99cf1684885bc721633aa9636ce3098 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 07:10:36 -0800 Subject: [PATCH 4/5] style(core): format the tempo spec CI's formatting check reads two lines that Prettier writes differently. --- .../motion/__tests__/themeTempo.interaction.browser.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts index e4d7b5887a72c..5fc2cb3f2a1f5 100644 --- a/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts @@ -113,7 +113,7 @@ async function travels( } test.describe('the springs follow the theme tempo (spec:AST-108 FR3)', () => { - test("a spring inside a tempo 1.5 scope draws at 1.5 × t what it draws at t outside it", async ({ + test('a spring inside a tempo 1.5 scope draws at 1.5 × t what it draws at t outside it', async ({ page, }) => { await openScopes(page); @@ -161,9 +161,7 @@ async function glideMs(page: Page, row: string): Promise { await section.getByRole('button', {name: 'Shipping details'}).click(); const after = section.locator('[data-after-collapsible]'); await expect - .poll(async () => - after.evaluate(element => element.getAnimations().length), - ) + .poll(async () => after.evaluate(element => element.getAnimations().length)) .toBeGreaterThan(0); return after.evaluate(element => { const glide = element From d9169fa84e20bff273f49d99bc10485927f0247f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 07:37:54 -0800 Subject: [PATCH 5/5] style(core): format the tempo unit test One call now fits on a line, as Prettier writes it. --- packages/core/src/motion/resolveMotion.tempo.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/motion/resolveMotion.tempo.test.ts b/packages/core/src/motion/resolveMotion.tempo.test.ts index 1ae54a907c282..81de8ce9e562f 100644 --- a/packages/core/src/motion/resolveMotion.tempo.test.ts +++ b/packages/core/src/motion/resolveMotion.tempo.test.ts @@ -83,10 +83,7 @@ describe("resolveMotion at the theme's tempo (spec:AST-108 FR3)", () => { 6, ); for (const seconds of [0.05, 0.15, 0.3]) { - expect(slowed.value(seconds * 1.5)).toBeCloseTo( - base.value(seconds), - 6, - ); + expect(slowed.value(seconds * 1.5)).toBeCloseTo(base.value(seconds), 6); } } expect(reads.every(name => name === TEMPO_PROPERTY)).toBe(true);