Sample the springs over M3's web durations, and add the Standard scheme

bin/springs.mjs is the sampler behind motion.css: damping ratio and stiffness in,
the 49-point linear() and the settle time out. Run with --settle it reproduces
the six curves the file carried byte for byte, so only the window changed.

Each spring is now sampled over the duration M3 publishes for the web (spatial
350/500/650 ms, effects 150/200/300 ms) instead of stopping at its own settle
time, so the *-duration tokens read Google's numbers. Sampling stays in real
time — point i is the spring at duration × i / 48 — so the bounce lands at the
same millisecond as before; a longer duration just holds the tail flat at 1, and
a shorter one pins the last point, at most 0.3% of the travel in one frame. Each
token's comment records damping, stiffness, settle time and sampled duration.

M3's second motion scheme is here too: [data-motion="standard"] swaps the three
spatial springs (damping 0.9, 0.2% overshoot instead of 9%) and their durations;
the effects springs sample identically in both schemes, so they are shared. The
reduced-motion block names both selectors, so it still zeroes every duration.

Plan: docs/plans/material-3-alignment.md step 5, findings core C8, C9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:29:32 +02:00
co-authored by Claude Fable 5.1
parent 2feb623ec0
commit 5b0ca26857
5 changed files with 289 additions and 28 deletions
+184
View File
@@ -0,0 +1,184 @@
/**
* Samples M3's motion springs into the `linear()` easings of resources/css/tokens/motion.css.
*
* Run from the repository root with `npm run build:springs` (or `node bin/springs.mjs`).
* Maintenance only: plain Node 22+, no dependencies, deterministic output — running it twice
* changes nothing. Paste the blocks it prints over the spring tokens in motion.css.
*
* node bin/springs.mjs every spring of both schemes, over Google's web durations
* node bin/springs.mjs --settle the same springs sampled to their own settle time instead
* node bin/springs.mjs 0.6 800 350 one spring: damping ratio, stiffness, duration in ms
*
* The maths is androidx's `SpringSimulation.updateValues` (Apache-2.0, © Google LLC) for a
* spring that starts at rest and travels from 0 to 1, which is what an easing has to describe.
* With the natural frequency ω = √stiffness, r = −ζω and the damped frequency ωd = ω√(1 ζ²):
*
* underdamped (ζ < 1) x(t) = 1 + e^(rt) · (cos(ωd·t) + (r / ωd) · sin(ωd·t))
* critical (ζ = 1) x(t) = 1 (1 + ω·t) · e^(−ω·t)
*
* The spatial springs are underdamped and overshoot; the effects springs are critically damped
* and never do (a colour must not overshoot). An overdamped spring (ζ > 1) is not in either
* scheme and is not implemented.
*
* *Settle time* here is the first whole 10 ms after which the spring stays within 0.1 % of its
* travel — found by scanning, because an underdamped spring crosses that band on the way to a
* later peak that may still be outside it. It is what the spring costs in real time, not a
* token: M3 publishes a duration per spring for the web, and that is what motion.css uses.
*
* *Sampling* takes the spring's value at real time `duration · i / 48` for i = 0…48 — 49 points,
* finer than a frame at 60 Hz — and rounds to four decimals, so the physics play out in real
* milliseconds whatever the duration is. Where the duration is past the settle time the tail is
* flat at 1; where it falls short the last point is pinned to 1 so the animation still lands
* exactly on its target, closing a gap of at most 0.3 % of the travel in the final frame.
*/
/** Compose's two motion schemes: {Expressive,Standard}MotionTokens.kt, androidx-main @ 1608250. */
const SCHEMES = {
expressive: {
'spatial-fast': { damping: 0.6, stiffness: 800 },
'spatial-default': { damping: 0.8, stiffness: 380 },
'spatial-slow': { damping: 0.8, stiffness: 200 },
'effects-fast': { damping: 1, stiffness: 3800 },
'effects-default': { damping: 1, stiffness: 1600 },
'effects-slow': { damping: 1, stiffness: 800 },
},
standard: {
'spatial-fast': { damping: 0.9, stiffness: 1400 },
'spatial-default': { damping: 0.9, stiffness: 700 },
'spatial-slow': { damping: 0.9, stiffness: 300 },
'effects-fast': { damping: 1, stiffness: 3800 },
'effects-default': { damping: 1, stiffness: 1600 },
'effects-slow': { damping: 1, stiffness: 800 },
},
}
/** The durations M3 publishes for the web, one per spring (docs/reference/m3/styles.md § Motion). */
const DURATIONS = {
expressive: {
'spatial-fast': 350,
'spatial-default': 500,
'spatial-slow': 650,
'effects-fast': 150,
'effects-default': 200,
'effects-slow': 300,
},
standard: {
'spatial-fast': 350,
'spatial-default': 500,
'spatial-slow': 750,
'effects-fast': 150,
'effects-default': 200,
'effects-slow': 300,
},
}
/** How close to its target the spring has to stay to count as settled: 0.1 % of the travel. */
const THRESHOLD = 0.001
/** The number of points a sampled easing has; 48 intervals divide every duration evenly. */
const POINTS = 49
/** The spring's value `ms` after it starts at rest and travels from 0 to 1. */
function spring({ damping, stiffness }, ms) {
const naturalFreq = Math.sqrt(stiffness)
const t = ms / 1000
if (damping > 1) {
throw new Error(`Overdamped springs (damping ${damping}) are not in either M3 scheme.`)
}
if (damping === 1) {
return 1 - (1 + naturalFreq * t) * Math.exp(-naturalFreq * t)
}
const r = -damping * naturalFreq
const dampedFreq = naturalFreq * Math.sqrt(1 - damping ** 2)
return 1 + Math.exp(r * t) * (-Math.cos(dampedFreq * t) + (r / dampedFreq) * Math.sin(dampedFreq * t))
}
/**
* The first whole 10 ms after which the spring never leaves the 0.1 % band again.
*
* Scanned in 0.05 ms steps up to where the decay envelope alone is inside the band (for the
* critically damped branch, √e times its half-life is a safe bound on the same idea), since the
* band can be re-entered and left again while the spring rings down.
*/
function settleTime(constants) {
const naturalFreq = Math.sqrt(constants.stiffness)
const decay = constants.damping * naturalFreq
const bound = ((Math.log(1 / THRESHOLD) + 10) / decay) * 1000
let last = 0
for (let ms = 0; ms <= bound; ms += 0.05) {
if (Math.abs(1 - spring(constants, ms)) >= THRESHOLD) {
last = ms
}
}
return Math.ceil(last / 10) * 10
}
/** Where an underdamped spring overshoots furthest: half a period of its damped oscillation. */
function peak(constants) {
if (constants.damping >= 1) {
return null
}
const dampedFreq = Math.sqrt(constants.stiffness) * Math.sqrt(1 - constants.damping ** 2)
const ms = (Math.PI / dampedFreq) * 1000
return { ms: Math.round(ms), value: spring(constants, ms) }
}
/** The spring as a `linear()` easing of 49 points over `duration` ms. */
function sample(constants, duration) {
const points = Array.from({ length: POINTS }, (_, i) => {
const value = spring(constants, (duration * i) / (POINTS - 1))
return Number(value.toFixed(4))
})
points[POINTS - 1] = 1
return `linear(${points.join(', ')})`
}
/** One spring as the CSS motion.css declares it: a derivation comment and the two tokens. */
function declare(name, constants, duration) {
const settles = settleTime(constants)
const overshoot = peak(constants)
const derivation = [
`damping ${constants.damping.toFixed(1)}, stiffness ${constants.stiffness}`,
`settles in ${settles}ms`,
overshoot ? `peaks at ${overshoot.value.toFixed(3)} at ${overshoot.ms}ms` : null,
`sampled over ${duration}ms`,
].filter(Boolean)
return [
` /* ${derivation.join('; ')} */`,
` --md-sys-motion-${name}: ${sample(constants, duration)};`,
` --md-sys-motion-${name}-duration: ${duration}ms;`,
].join('\n')
}
const args = process.argv.slice(2)
const toSettle = args.includes('--settle')
const numbers = args.filter((argument) => !argument.startsWith('--')).map(Number)
if (numbers.length >= 2) {
const constants = { damping: numbers[0], stiffness: numbers[1] }
process.stdout.write(`${declare('custom', constants, numbers[2] ?? settleTime(constants))}\n`)
} else {
const blocks = Object.entries(SCHEMES).map(([scheme, springs]) =>
[
`/* ${scheme} */`,
...Object.entries(springs).map(([name, constants]) =>
declare(name, constants, toSettle ? settleTime(constants) : DURATIONS[scheme][name]),
),
].join('\n'),
)
process.stdout.write(`${blocks.join('\n\n')}\n`)
}
+1
View File
@@ -8,6 +8,7 @@
"build:scheme": "esbuild bin/scheme.mjs --bundle --platform=node --format=esm --target=node20 --minify --legal-comments=eof --outfile=resources/node/scheme.mjs",
"build:shapes": "node bin/shapes.mjs",
"build:loading": "node bin/loading-indicator.mjs",
"build:springs": "node bin/springs.mjs",
"check:font": "node bin/check-font.mjs"
},
"devDependencies": {
@@ -84,7 +84,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking. Never assemble `text-*`, `leading-*` and `tracking-*` by hand; a utility carries size, line height and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular utility inside an emphasized one goes back to plain. The font is Google Sans Flex (`font-sans`).
- Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`.
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone).
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`. Reduced motion zeroes the durations.
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity, which never overshoot). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast` — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. `motion.scheme` in the config picks `expressive` (the default, with the bounce) or `standard` (minimal bounce), which the head script writes to `<html data-motion>` and which swaps the three spatial springs; a component names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
- States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text).
- `dark:` follows the page's theme (`data-theme`), not the operating system.
- `x-figure` on an element holding one number counts it up on first appearance and on change.
+62 -25
View File
@@ -1,53 +1,69 @@
/*
* Motion, as M3 Expressive's springs.
* Motion, as M3's springs.
*
* M3 Expressive moves on physics, not on a duration and a curve: every transition is a
* spring with a damping ratio and a stiffness. CSS cannot run a spring, so each one is
* sampled into a `linear()` easing over the time it takes to settle (to within 0.1% of its
* travel) — 49 points, past the resolution anyone can see at 60 Hz. The constants are
* androidx Compose Material 3's own (tokens/ExpressiveMotionTokens.kt, Apache-2.0):
* sampled into a `linear()` easing — 49 points, past the resolution anyone can see at 60 Hz —
* by bin/springs.mjs (`npm run build:springs`), which carries the maths and the derivation.
* The constants are androidx Compose Material 3's own ({Expressive,Standard}MotionTokens.kt,
* Apache-2.0, © Google LLC, androidx-main @ 1608250):
*
* spatial — position, size, shape. Underdamped, so the fast one overshoots by 9% and
* settles back: that small bounce is what reads as Expressive.
* spatial — position, size, shape. Underdamped, so the Expressive fast one overshoots by 9%
* and settles back: that small bounce is what reads as Expressive.
* effects — colour and opacity. Critically damped; a colour must never overshoot.
*
* Each spring is sampled over the duration M3 publishes for the web
* (docs/reference/m3/styles.md § Motion, "Web curve equivalents"), so the `*-duration` tokens
* read Google's numbers: spatial 350/500/650 ms, effects 150/200/300 ms. Sampling is in real
* time — point i is the spring's value at `duration × i / 48` — so the bounce lands at the same
* millisecond whatever the duration is. Where the duration outlasts the spring the tail is flat
* at 1; where it is shorter the final point is pinned to 1, which closes at most 0.3% of the
* travel in the last frame. Each token's comment records damping, stiffness, the settle time
* (when the spring stays within 0.1% of its travel) and the duration it was sampled over.
*
* Use a pair together, easing and duration, or the curve is stretched over the wrong time
* and the bounce lands late:
*
* transition: transform var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
* class="transition-transform duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast"
*
* M3 has two motion schemes. Expressive is the default here and the values above; Standard
* ("minimal bounce, for utilitarian products") swaps the three spatial springs when the theme
* script writes `data-motion="standard"` on <html>. Its effects springs are the same ones, so
* only spatial is overridden. A component never names a scheme: it names a spring.
*
* The cubic-bezier easings below are M3's non-spring set (@material/web token values,
* Apache-2.0, © Google LLC), for the few places a duration is fixed by something outside
* the page — a view transition, an animated scroll.
*
* Reduced motion zeroes every duration here, so anything that animates through these tokens
* turns instant with no per-component check; anything that animates without them is a bug.
* Reduced motion zeroes every duration here, both schemes, so anything that animates through
* these tokens turns instant with no per-component check; anything that animates without them
* is a bug.
*/
:root {
/* damping 0.6, stiffness 800; settles in 360ms, peaks at 1.094 */
--md-sys-motion-spatial-fast: linear(0, 0.0206, 0.0754, 0.1544, 0.2492, 0.3524, 0.458, 0.5613, 0.6588, 0.7479, 0.8269, 0.895, 0.952, 0.9981, 1.0338, 1.0603, 1.0783, 1.0893, 1.0942, 1.0943, 1.0906, 1.0842, 1.0759, 1.0665, 1.0565, 1.0465, 1.037, 1.0281, 1.0201, 1.0131, 1.0072, 1.0023, 0.9984, 0.9955, 0.9934, 0.992, 0.9912, 0.991, 0.9912, 0.9917, 0.9924, 0.9932, 0.9942, 0.9951, 0.996, 0.9969, 0.9977, 0.9984, 1);
--md-sys-motion-spatial-fast-duration: 360ms;
/* damping 0.6, stiffness 800; settles in 360ms; peaks at 1.095 at 139ms; sampled over 350ms */
--md-sys-motion-spatial-fast: linear(0, 0.0195, 0.0716, 0.1471, 0.2381, 0.3378, 0.4404, 0.5416, 0.6378, 0.7265, 0.806, 0.8754, 0.9342, 0.9826, 1.0211, 1.0503, 1.0713, 1.0849, 1.0924, 1.0948, 1.0931, 1.0882, 1.0812, 1.0726, 1.0632, 1.0534, 1.0438, 1.0347, 1.0263, 1.0187, 1.0121, 1.0064, 1.0018, 0.9981, 0.9953, 0.9933, 0.992, 0.9913, 0.991, 0.9912, 0.9916, 0.9923, 0.9931, 0.994, 0.9949, 0.9958, 0.9967, 0.9975, 1);
--md-sys-motion-spatial-fast-duration: 350ms;
/* damping 0.8, stiffness 380; settles in 440ms, peaks at 1.015 */
--md-sys-motion-spatial-default: linear(0, 0.0145, 0.0527, 0.1076, 0.1736, 0.2461, 0.3214, 0.3968, 0.4702, 0.54, 0.6051, 0.665, 0.7193, 0.7679, 0.8108, 0.8483, 0.8807, 0.9083, 0.9316, 0.951, 0.9669, 0.9798, 0.99, 0.998, 1.004, 1.0085, 1.0116, 1.0136, 1.0147, 1.0151, 1.0151, 1.0146, 1.0138, 1.0128, 1.0118, 1.0106, 1.0095, 1.0084, 1.0073, 1.0063, 1.0053, 1.0045, 1.0037, 1.0031, 1.0025, 1.002, 1.0015, 1.0011, 1);
--md-sys-motion-spatial-default-duration: 440ms;
/* damping 0.8, stiffness 380; settles in 440ms; peaks at 1.015 at 269ms; sampled over 500ms */
--md-sys-motion-spatial-default: linear(0, 0.0185, 0.0663, 0.1336, 0.2126, 0.2973, 0.3832, 0.4669, 0.5461, 0.6192, 0.6854, 0.7443, 0.7958, 0.8402, 0.8779, 0.9094, 0.9354, 0.9564, 0.9731, 0.9861, 0.996, 1.0033, 1.0085, 1.0119, 1.014, 1.015, 1.0152, 1.0148, 1.014, 1.0129, 1.0117, 1.0104, 1.0091, 1.0078, 1.0066, 1.0055, 1.0046, 1.0037, 1.0029, 1.0023, 1.0017, 1.0013, 1.0009, 1.0006, 1.0004, 1.0002, 1, 0.9999, 1);
--md-sys-motion-spatial-default-duration: 500ms;
/* damping 0.8, stiffness 200; settles in 600ms, peaks at 1.015 */
--md-sys-motion-spatial-slow: linear(0, 0.0142, 0.0517, 0.1057, 0.1706, 0.2421, 0.3166, 0.3912, 0.464, 0.5334, 0.5984, 0.6583, 0.7127, 0.7615, 0.8047, 0.8426, 0.8755, 0.9036, 0.9274, 0.9473, 0.9638, 0.9771, 0.9878, 0.9962, 1.0027, 1.0074, 1.0108, 1.0131, 1.0144, 1.0151, 1.0151, 1.0148, 1.0141, 1.0132, 1.0122, 1.0111, 1.0099, 1.0088, 1.0077, 1.0067, 1.0057, 1.0049, 1.0041, 1.0034, 1.0027, 1.0022, 1.0017, 1.0013, 1);
--md-sys-motion-spatial-slow-duration: 600ms;
/* damping 0.8, stiffness 200; settles in 600ms; peaks at 1.015 at 370ms; sampled over 650ms */
--md-sys-motion-spatial-slow: linear(0, 0.0166, 0.0597, 0.1211, 0.1939, 0.273, 0.354, 0.434, 0.5107, 0.5826, 0.6487, 0.7084, 0.7615, 0.8081, 0.8484, 0.8829, 0.912, 0.9361, 0.9559, 0.9719, 0.9845, 0.9943, 1.0017, 1.0071, 1.0108, 1.0132, 1.0146, 1.0151, 1.0151, 1.0145, 1.0137, 1.0126, 1.0114, 1.0102, 1.009, 1.0078, 1.0067, 1.0057, 1.0047, 1.0039, 1.0031, 1.0025, 1.0019, 1.0015, 1.0011, 1.0008, 1.0005, 1.0003, 1);
--md-sys-motion-spatial-slow-duration: 650ms;
/* damping 1.0, stiffness 3800; settles in 150ms */
/* damping 1.0, stiffness 3800; settles in 150ms; sampled over 150ms */
--md-sys-motion-effects-fast: linear(0, 0.0163, 0.0576, 0.1147, 0.1807, 0.2507, 0.3214, 0.3902, 0.4558, 0.5172, 0.5737, 0.6253, 0.6718, 0.7136, 0.7508, 0.7837, 0.8128, 0.8383, 0.8606, 0.8801, 0.897, 0.9117, 0.9244, 0.9353, 0.9448, 0.9529, 0.9599, 0.9658, 0.9709, 0.9753, 0.979, 0.9822, 0.9849, 0.9872, 0.9892, 0.9909, 0.9923, 0.9935, 0.9945, 0.9954, 0.9961, 0.9967, 0.9972, 0.9977, 0.998, 0.9983, 0.9986, 0.9988, 1);
--md-sys-motion-effects-fast-duration: 150ms;
/* damping 1.0, stiffness 1600; settles in 240ms */
--md-sys-motion-effects-default: linear(0, 0.0175, 0.0616, 0.1219, 0.1912, 0.2642, 0.3374, 0.4082, 0.4751, 0.5372, 0.594, 0.6454, 0.6916, 0.7326, 0.7689, 0.8009, 0.8288, 0.8532, 0.8743, 0.8926, 0.9084, 0.922, 0.9337, 0.9437, 0.9523, 0.9596, 0.9658, 0.9711, 0.9756, 0.9794, 0.9826, 0.9854, 0.9877, 0.9897, 0.9913, 0.9927, 0.9939, 0.9949, 0.9957, 0.9964, 0.997, 0.9975, 0.9979, 0.9982, 0.9985, 0.9988, 0.999, 0.9991, 1);
--md-sys-motion-effects-default-duration: 240ms;
/* damping 1.0, stiffness 1600; settles in 240ms; sampled over 200ms */
--md-sys-motion-effects-default: linear(0, 0.0124, 0.0446, 0.0902, 0.1443, 0.2032, 0.2642, 0.3253, 0.3849, 0.4422, 0.4963, 0.547, 0.594, 0.6372, 0.6768, 0.7127, 0.7452, 0.7745, 0.8009, 0.8244, 0.8454, 0.8641, 0.8807, 0.8954, 0.9084, 0.9199, 0.93, 0.9389, 0.9467, 0.9536, 0.9596, 0.9648, 0.9694, 0.9734, 0.9769, 0.98, 0.9826, 0.985, 0.987, 0.9887, 0.9902, 0.9916, 0.9927, 0.9937, 0.9946, 0.9953, 0.9959, 0.9965, 1);
--md-sys-motion-effects-default-duration: 200ms;
/* damping 1.0, stiffness 800; settles in 330ms */
--md-sys-motion-effects-slow: linear(0, 0.0166, 0.0586, 0.1165, 0.1833, 0.254, 0.3253, 0.3947, 0.4606, 0.5221, 0.5788, 0.6303, 0.6768, 0.7184, 0.7554, 0.7881, 0.8169, 0.8421, 0.8641, 0.8833, 0.8999, 0.9144, 0.9268, 0.9375, 0.9467, 0.9546, 0.9614, 0.9672, 0.9722, 0.9764, 0.98, 0.9831, 0.9857, 0.9879, 0.9898, 0.9914, 0.9927, 0.9939, 0.9948, 0.9956, 0.9963, 0.9969, 0.9974, 0.9978, 0.9982, 0.9985, 0.9987, 0.9989, 1);
--md-sys-motion-effects-slow-duration: 330ms;
/* damping 1.0, stiffness 800; settles in 330ms; sampled over 300ms */
--md-sys-motion-effects-slow: linear(0, 0.0139, 0.0496, 0.0995, 0.1583, 0.2216, 0.2865, 0.3509, 0.4131, 0.4722, 0.5275, 0.5788, 0.6258, 0.6687, 0.7075, 0.7424, 0.7737, 0.8016, 0.8264, 0.8484, 0.8678, 0.8849, 0.8999, 0.9131, 0.9247, 0.9347, 0.9435, 0.9512, 0.9578, 0.9636, 0.9686, 0.973, 0.9767, 0.98, 0.9828, 0.9852, 0.9873, 0.9891, 0.9907, 0.992, 0.9931, 0.9941, 0.995, 0.9957, 0.9963, 0.9969, 0.9973, 0.9977, 1);
--md-sys-motion-effects-slow-duration: 300ms;
--md-sys-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1);
--md-sys-motion-easing-standard-accelerate: cubic-bezier(0.3, 0, 1, 1);
@@ -60,8 +76,29 @@
--md-sys-motion-duration-long: 500ms;
}
/*
* The Standard scheme: the same three spatial springs at a damping ratio of 0.9, which leaves
* a 0.2% overshoot instead of Expressive's 9% — motion that eases into place rather than
* bouncing. Google's web durations for them are 350/500/750 ms; the effects springs, the
* legacy easings and every other token are the ones above.
*/
[data-motion="standard"] {
/* damping 0.9, stiffness 1400; settles in 230ms; peaks at 1.002 at 193ms; sampled over 350ms */
--md-sys-motion-spatial-fast: linear(0, 0.0316, 0.1076, 0.2062, 0.313, 0.4185, 0.517, 0.6056, 0.6828, 0.7486, 0.8036, 0.8487, 0.8852, 0.9142, 0.937, 0.9546, 0.968, 0.9781, 0.9856, 0.991, 0.9948, 0.9975, 0.9992, 1.0004, 1.001, 1.0014, 1.0015, 1.0015, 1.0014, 1.0013, 1.0011, 1.001, 1.0008, 1.0007, 1.0005, 1.0004, 1.0003, 1.0003, 1.0002, 1.0001, 1.0001, 1.0001, 1.0001, 1, 1, 1, 1, 1, 1);
--md-sys-motion-spatial-fast-duration: 350ms;
/* damping 0.9, stiffness 700; settles in 320ms; peaks at 1.002 at 272ms; sampled over 500ms */
--md-sys-motion-spatial-default: linear(0, 0.0322, 0.1094, 0.2094, 0.3173, 0.4237, 0.5227, 0.6114, 0.6886, 0.7541, 0.8086, 0.8532, 0.8891, 0.9175, 0.9397, 0.9569, 0.9699, 0.9796, 0.9867, 0.9918, 0.9954, 0.9979, 0.9995, 1.0006, 1.0011, 1.0014, 1.0015, 1.0015, 1.0014, 1.0012, 1.0011, 1.0009, 1.0007, 1.0006, 1.0005, 1.0004, 1.0003, 1.0002, 1.0002, 1.0001, 1.0001, 1.0001, 1, 1, 1, 1, 1, 1, 1);
--md-sys-motion-spatial-default-duration: 500ms;
/* damping 0.9, stiffness 300; settles in 490ms; peaks at 1.002 at 416ms; sampled over 750ms */
--md-sys-motion-spatial-slow: linear(0, 0.0311, 0.1061, 0.2037, 0.3095, 0.4143, 0.5125, 0.6009, 0.6782, 0.7442, 0.7995, 0.8451, 0.882, 0.9115, 0.9347, 0.9527, 0.9665, 0.9769, 0.9846, 0.9903, 0.9943, 0.9971, 0.999, 1.0002, 1.0009, 1.0013, 1.0015, 1.0015, 1.0014, 1.0013, 1.0012, 1.001, 1.0008, 1.0007, 1.0006, 1.0005, 1.0004, 1.0003, 1.0002, 1.0002, 1.0001, 1.0001, 1.0001, 1, 1, 1, 1, 1, 1);
--md-sys-motion-spatial-slow-duration: 750ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
:root,
[data-motion="standard"] {
--md-sys-motion-spatial-fast-duration: 0ms;
--md-sys-motion-spatial-default-duration: 0ms;
--md-sys-motion-spatial-slow-duration: 0ms;
+41 -2
View File
@@ -113,13 +113,16 @@ it('reads every safe-area inset through a variable that can replace it', functio
->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));');
});
it('makes every motion token instant under reduced motion', function () {
it('makes every motion token instant under reduced motion, in both schemes', function () {
$motion = File::get(packageCss('tokens/motion.css'));
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
preg_match_all('/(--md-sys-motion-[\w-]*duration[\w-]*):/', Str::before($motion, '@media'), $durations);
expect($durations[1])->not->toBeEmpty();
expect($durations[1])->not->toBeEmpty()
// The Standard scheme sets the same variables on a selector of its own, so :root alone
// would not reach it: the block has to name both.
->and($reduced)->toMatch('/:root,\s*\[data-motion="standard"\] \{/');
foreach ($durations[1] as $duration) {
expect($reduced)->toContain("{$duration}: 0ms;");
@@ -164,3 +167,39 @@ it('gives every emphasized type style its own tracking, as Compose does', functi
'--md-sys-typescale-emphasized-label-md-tracking' => '0.03125rem',
]);
});
it('reads every spring over the duration M3 publishes for the web', function () {
$expressive = declarations(File::get(packageCss('tokens/motion.css')), ':root');
// docs/reference/m3/styles.md § Motion, "Web curve equivalents for springs".
$durations = [
'spatial-fast' => '350ms', 'spatial-default' => '500ms', 'spatial-slow' => '650ms',
'effects-fast' => '150ms', 'effects-default' => '200ms', 'effects-slow' => '300ms',
];
foreach ($durations as $spring => $duration) {
expect($expressive["--md-sys-motion-{$spring}-duration"])->toBe($duration)
// 49 sampled points, starting at rest and landing exactly on the target.
->and($expressive["--md-sys-motion-{$spring}"])->toStartWith('linear(0, ')->toEndWith(', 1)')
->and(explode(', ', $expressive["--md-sys-motion-{$spring}"]))->toHaveCount(49);
}
});
it('swaps only the spatial springs for the Standard motion scheme', function () {
$motion = File::get(packageCss('tokens/motion.css'));
$expressive = declarations($motion, ':root');
$standard = declarations($motion, '[data-motion="standard"] {');
// Both schemes share the effects springs, so the block carries the three spatial pairs only.
expect(array_keys($standard))->toBe([
'--md-sys-motion-spatial-fast', '--md-sys-motion-spatial-fast-duration',
'--md-sys-motion-spatial-default', '--md-sys-motion-spatial-default-duration',
'--md-sys-motion-spatial-slow', '--md-sys-motion-spatial-slow-duration',
]);
foreach (['fast' => '350ms', 'default' => '500ms', 'slow' => '750ms'] as $speed => $duration) {
expect($standard["--md-sys-motion-spatial-{$speed}-duration"])->toBe($duration)
->and($standard["--md-sys-motion-spatial-{$speed}"])->toStartWith('linear(0, ')
->not->toBe($expressive["--md-sys-motion-spatial-{$speed}"]);
}
});