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
185 lines
7.3 KiB
JavaScript
185 lines
7.3 KiB
JavaScript
/**
|
||
* 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`)
|
||
}
|