Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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

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