From 9359e05b8b3233c8a94e7a41f1c84eb1c36359be Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 05:13:40 -0800 Subject: [PATCH 1/2] 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. Finger tracking, ProgressBar's busy loop and curves stay as measured (spec:AST-108 FR4). A theme with only a duration scale writes no tempo, so its springs stay at tempo 1 (FR5). The springs don't follow `character` yet; that part waits on its record. A `Core/Theme motion tempo` story shows three themes side by side for a feel check on a touch screen, and its glide is the browser proof's fixture. --- .changeset/theme-tempo-springs.md | 8 + .../stories/ThemeMotionTempo.stories.tsx | 119 ++++++++++++ packages/cli/assets/docs/motion.doc.mjs | 2 +- .../themeTempo.interaction.browser.spec.ts | 180 +++++++++++++++++ .../core/src/motion/animatedLayout.test.ts | 15 ++ packages/core/src/motion/animatedLayout.ts | 15 +- .../src/motion/resolveMotion.tempo.test.ts | 182 ++++++++++++++++++ .../core/src/motion/resolveMotion.test.ts | 17 +- packages/core/src/motion/resolveMotion.ts | 103 ++++++++-- packages/core/src/motion/themeTempo.test.ts | 52 +++++ packages/core/src/motion/themeTempo.ts | 40 ++++ 11 files changed, 703 insertions(+), 30 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..183112d393638 --- /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 leaves the springs at tempo 1, and 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..4003bcb825f93 --- /dev/null +++ b/apps/storybook/stories/ThemeMotionTempo.stories.tsx @@ -0,0 +1,119 @@ +// 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; + +/** + * 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 containerRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef}); + const [isOn, setIsOn] = useState(false); + const [volume, setVolume] = useState(40); + const [view, setView] = useState('day'); + return ( +
+
{NAMES[tempo]}
+ +
+ + + + + + + + + +

Ships in 2 business days.

+

Free returns for 30 days.

+
+

+ The content after it glides on the theme's 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) => ( + + ))} +
+ ), +}; 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/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..99349f0b6188f --- /dev/null +++ b/packages/core/src/motion/__tests__/themeTempo.interaction.browser.spec.ts @@ -0,0 +1,180 @@ +// 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 + * @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'; + +/** The glide's length for the content after the Collapsible in `tempo`'s row. */ +async function glideMs(page: Page, tempo: string): Promise { + const row = page.locator(`section[data-tempo="${tempo}"]`); + await row.getByRole('button', {name: 'Shipping details'}).click(); + const after = row.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); + }); +} + +test('the glide under three themes lasts 0.75, 1 and 1.5 times as long', async ({ + page, +}) => { + await page.goto( + `${storybook?.origin ?? ''}/iframe.html?id=${STORY}&viewMode=story`, + ); + const rows = page.locator('section[data-tempo]'); + await expect(rows).toHaveCount(3); + for (const box of await rows.evaluateAll(sections => + sections.map(section => section.getBoundingClientRect().height), + )) { + expect(box).toBeGreaterThan(0); + } + const atOne = await glideMs(page, '1'); + expect(atOne).toBeGreaterThan(300); + expect((await glideMs(page, '0.75')) / atOne).toBeCloseTo(0.75, 2); + expect((await glideMs(page, '1.5')) / atOne).toBeCloseTo(1.5, 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..5adb96cbb5f39 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, @@ -138,11 +140,12 @@ 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. + * The glide plays it at the tempo of the theme `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/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..3d58d4bf2fbc2 100644 --- a/packages/core/src/motion/resolveMotion.ts +++ b/packages/core/src/motion/resolveMotion.ts @@ -3,8 +3,9 @@ /** * @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 @@ -12,23 +13,77 @@ * 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. + * 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..3f683d98e0e7d --- /dev/null +++ b/packages/core/src/motion/themeTempo.ts @@ -0,0 +1,40 @@ +// 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 duration scale alone writes none, so its + * springs stay at tempo 1. 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; +} From a995a809b6c71c452f8433c0b45395b08f7b4561 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 05:16:58 -0800 Subject: [PATCH 2/2] feat(core): a CSS part times itself from its spring's settle A CSS part that plays alongside a spring, such as a scrim fading under a sheet, has to start and land with it. Timed on its own clock, it drifts once a theme's tempo changes the spring. resolveSettleMs(element, spring) returns the resolved spring's settling duration at the element's tempo. That is the length the layout glide plays for the same role, so a glide and a part timed from it land together. A fixed role, or a spring that is not a role, gives its own. No component uses it yet. --- .changeset/motion-settle-clock.md | 7 +++ .../core/src/motion/animatedLayout.test.ts | 12 +++++ .../src/motion/resolveMotion.tempo.test.ts | 50 +++++++++++++++++-- packages/core/src/motion/resolveMotion.ts | 26 +++++++++- 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 .changeset/motion-settle-clock.md diff --git a/.changeset/motion-settle-clock.md b/.changeset/motion-settle-clock.md new file mode 100644 index 0000000000000..6c012b3f1ad27 --- /dev/null +++ b/.changeset/motion-settle-clock.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the motion foundation's `resolveSettleMs` gives a spring role's settling time at the theme's tempo, so a CSS part that plays with the spring can start and land with it. The layout glide lasts that same time. No component uses it yet. Not exported from the package entry point. + +@vjeux diff --git a/packages/core/src/motion/animatedLayout.test.ts b/packages/core/src/motion/animatedLayout.test.ts index 4f2fc781c9086..60a04816ca694 100644 --- a/packages/core/src/motion/animatedLayout.test.ts +++ b/packages/core/src/motion/animatedLayout.test.ts @@ -21,6 +21,8 @@ import { releaseLayoutExit, reportLayoutChange, } from './animatedLayout'; +import {resolveSettleMs} from './resolveMotion'; +import {springs} from './uikit/springs'; interface Played { element: Element; @@ -381,6 +383,16 @@ describe('one shared motion (spec:AST-105 FR4)', () => { expect(own?.options.duration).toBe(follower?.options.duration); }); + it('lasts what a CSS part timed from springs.settle lasts in the same theme, so the two land together', async () => { + await changeOnce('--_astryx-motion-tempo: 1.2'); + const area = document.getElementById('area') as HTMLElement; + const follower = played.find(p => (p.element as HTMLElement).id === 'a'); + expect(Number(follower?.options.duration)).toBeCloseTo( + resolveSettleMs(area, springs.settle), + 6, + ); + }); + 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/resolveMotion.tempo.test.ts b/packages/core/src/motion/resolveMotion.tempo.test.ts index 0a0e24d372aee..f1bb457e5378d 100644 --- a/packages/core/src/motion/resolveMotion.tempo.test.ts +++ b/packages/core/src/motion/resolveMotion.tempo.test.ts @@ -8,15 +8,21 @@ * 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 + * is never scaled twice. The animator plays the slowed spring. A CSS part + * timed from a spring lasts the resolved spring's settling duration * @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 {resolveMotion, resolveSettleMs} from './resolveMotion'; +import { + settlingDuration, + solveSpring, + toSpring, + type SpringDescription, +} from './spring'; import { progressBar, registerProgressBarTable, @@ -164,6 +170,44 @@ describe("resolveMotion at the theme's tempo (spec:AST-108 FR3)", () => { }); }); +describe('a CSS part timed from its spring', () => { + const settleMs = (spring: SpringDescription) => + settlingDuration(toSpring(spring)) * 1000; + + it("lasts the role's settling duration where no theme sets a tempo", () => { + const target = element(); + expect(resolveSettleMs(target, springs.settle)).toBe( + settleMs(springs.settle), + ); + // SwiftUI's smooth spring (0.5 s, no bounce) settles in about 0.6 s. + expect(resolveSettleMs(target, springs.settle)).toBeGreaterThan(400); + expect(resolveSettleMs(target, springs.settle)).toBeLessThan(900); + }); + + it('takes tempo times as long with the spring it plays with', () => { + const target = element(); + const plain = resolveSettleMs(target, springs.settle); + tempo = '1.5'; + expect(resolveSettleMs(target, springs.settle)).toBeCloseTo( + plain * 1.5, + 6, + ); + expect(resolveSettleMs(target, springs.settle)).toBe( + settleMs(resolveMotion(target, springs.settle)), + ); + }); + + it('stays as measured for a finger-following role, and for a spring that is not a role', () => { + tempo = '1.5'; + const target = element(); + expect(resolveSettleMs(target, springs.fingerTracking)).toBe( + settleMs(springs.fingerTracking), + ); + const inline = {response: 0.4, dampingRatio: 0.9}; + expect(resolveSettleMs(target, inline)).toBe(settleMs(inline)); + }); +}); + describe('the animator', () => { it("plays a role's spring at the theme's tempo", () => { tempo = '1.5'; diff --git a/packages/core/src/motion/resolveMotion.ts b/packages/core/src/motion/resolveMotion.ts index 3d58d4bf2fbc2..39755f4f346f7 100644 --- a/packages/core/src/motion/resolveMotion.ts +++ b/packages/core/src/motion/resolveMotion.ts @@ -5,7 +5,9 @@ * @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. The element the motion * plays on, for its theme's tempo (themeTempo.ts) - * @output resolveMotion: the role's value at the theme's tempo + * @output resolveMotion: the role's value at the theme's tempo. + * resolveSettleMs: how long a spring role takes to settle there, for a CSS + * part that plays with it * @position Motion foundation; the animator resolves every spring and curve * it is handed with it, and components resolve the durations they time * with it @@ -29,7 +31,7 @@ import {motionRoleIn, motionRoleOf} from './roles'; import {isSpring} from './roleValue'; import type {MotionCurvePoints} from './roles'; -import type {SpringDescription} from './spring'; +import {settlingDuration, toSpring, type SpringDescription} from './spring'; import {themeTempo} from './themeTempo'; /** @@ -137,3 +139,23 @@ export function resolveMotion( } return isSpring(value) ? springAtTempo(value, tempo) : value; } + +/** + * How long `spring` takes to settle, in milliseconds, at the tempo of the + * theme `element` is in. A CSS part that plays with the spring takes this + * duration, such as a scrim's fade under a sheet. Then the two start and land + * together on one clock. It is the resolved spring's settling duration, the + * length the layout glide plays for the same role. A fixed role, or a spring + * that is not a role, gives its own. + * + * @example + * ``` + * scrim.style.transitionDuration = `${resolveSettleMs(sheet, springs.settle)}ms`; + * ``` + */ +export function resolveSettleMs( + element: Element | null | undefined, + spring: SpringDescription, +): number { + return settlingDuration(toSpring(resolveMotion(element, spring))) * 1000; +}