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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/motion-settle-clock.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .changeset/theme-tempo-springs.md
Original file line number Diff line number Diff line change
@@ -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
119 changes: 119 additions & 0 deletions apps/storybook/stories/ThemeMotionTempo.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const animatedLayout = useAnimatedLayout({containerRef});
const [isOn, setIsOn] = useState(false);
const [volume, setVolume] = useState(40);
const [view, setView] = useState('day');
return (
<section data-tempo={tempo} style={{margin: '0 0 20px', maxWidth: 420}}>
<div style={title}>{NAMES[tempo]}</div>
<Theme theme={THEMES[index]} mode="light">
<div style={card}>
<Switch label="Wi-Fi" value={isOn} onChange={setIsOn} />
<Slider label="Volume" value={volume} onChange={setVolume} />
<SegmentedControl value={view} onChange={setView} label="Range">
<SegmentedControlItem value="day" label="Day" />
<SegmentedControlItem value="week" label="Week" />
<SegmentedControlItem value="month" label="Month" />
</SegmentedControl>
<ScrollableArea
ref={containerRef}
height={180}
label={`Details at tempo ${tempo}`}>
<Collapsible
trigger="Shipping details"
defaultIsOpen={false}
animatedLayout={animatedLayout}>
<p style={{margin: '4px 0'}}>Ships in 2 business days.</p>
<p style={{margin: '4px 0'}}>Free returns for 30 days.</p>
</Collapsible>
<p data-after-collapsible style={{margin: '8px 0 0'}}>
The content after it glides on the theme&apos;s tempo.
</p>
</ScrollableArea>
</div>
</Theme>
</section>
);
}

/**
* 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: () => (
<div>
{TEMPOS.map((tempo, index) => (
<Row key={tempo} index={index} />
))}
</div>
),
};
2 changes: 1 addition & 1 deletion packages/cli/assets/docs/motion.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html, body { margin: 0; background: #fff; }
.box { position: relative; width: 48px; height: 48px; margin: 8px 0; background: #36c; }
.slow { --_astryx-motion-tempo: 1.5; }
</style>
<div class="box" id="base"></div>
<div class="slow"><section><div class="box" id="slowed"></div></section></div>
<script>${script}</script>
<script>Motion.registerSpringsTable();</script>`;
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<number> {
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);
});
27 changes: 27 additions & 0 deletions packages/core/src/motion/animatedLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
releaseLayoutExit,
reportLayoutChange,
} from './animatedLayout';
import {resolveSettleMs} from './resolveMotion';
import {springs} from './uikit/springs';

interface Played {
element: Element;
Expand Down Expand Up @@ -366,6 +368,31 @@ 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('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;
Expand Down
Loading