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 b74e18c8494e2..e278e35be4b6c 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 888f997b53878..f3ccd90aa2669 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 bb8df901afb34..4e50f27d9bb08 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..5fc2cb3f2a1f5 --- /dev/null +++ b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts @@ -0,0 +1,226 @@ +// 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.createMotionEngine(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 ba5ec8421a5f8..9eb2f81cd3fad 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 createMotionEngine( 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..81de8ce9e562f --- /dev/null +++ b/packages/core/src/motion/resolveMotion.tempo.test.ts @@ -0,0 +1,179 @@ +// 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 {createMotionEngine} 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 = 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}); + 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 3045170905ac6..0fd8c371a3d20 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';