Skip to content
Merged
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
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 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
167 changes: 167 additions & 0 deletions apps/storybook/stories/ThemeMotionTempo.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const animatedLayout = useAnimatedLayout({containerRef});
return (
<ScrollableArea ref={containerRef} height={180} label={label}>
<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>
);
}

/**
* 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 (
<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>
<Details label={`Details at tempo ${tempo}`} />
</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>
),
};

/** 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: () => (
<div style={{maxWidth: 420}}>
<section data-row="plain" style={{margin: '0 0 20px'}}>
<div style={title}>No theme tempo</div>
<div style={card}>
<Details label="Details with no theme tempo" />
</div>
</section>
<Theme theme={THEMES[TEMPOS.indexOf(1.5)]} mode="light">
<section data-row="outer" style={{margin: '0 0 20px'}}>
<div style={title}>Tempo 1.5</div>
<div style={card}>
<Details label="Details at tempo 1.5" />
</div>
</section>
<Theme theme={durationScaleTheme} mode="light">
<section data-row="nested" style={{margin: '0 0 20px'}}>
<div style={title}>A duration scale inside tempo 1.5</div>
<div style={card}>
<Details label="Details in a duration scale" />
</div>
</section>
</Theme>
</Theme>
</div>
),
};
2 changes: 1 addition & 1 deletion packages/cli/assets/docs/author-a-theme.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}.'},
],
Expand Down
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.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These still say the opposite: defineTheme.ts:296-297 ("touch motion's springs don't read it yet"), packages/cli/assets/theme.template.ts:184-185, packages/cli/assets/docs/author-a-theme.doc.mjs:44 and packages/core/src/theme/themeMotion.ts:16 ("No spring reads the knobs yet"). Please update them to match this sentence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6755a6a: defineTheme.ts, the theme template, author-a-theme.doc.mjs and the themeMotion.ts header now say the springs follow tempo but not character yet.

},
{
type: 'list',
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/assets/theme.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]')
Expand Down
46 changes: 44 additions & 2 deletions packages/core/src/Switch/useSwitchTouch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, MotionCurve>).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);
});
});
18 changes: 13 additions & 5 deletions packages/core/src/Switch/useSwitchTouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}),
});
}
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/motion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `register<Name>Table()` 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
Expand Down
Loading
Loading