Merge branch 'worktree-agent-afa50210f14592c5f'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:30:12 +02:00
9 changed files with 655 additions and 53 deletions
+60
View File
@@ -0,0 +1,60 @@
/**
* Checks that the packaged Google Sans Flex subset still carries the variable axes the CSS uses.
*
* Run from the repository root with `npm run check:font` (or `node bin/check-font.mjs [woff2]`).
* tests/Feature/FontTest.php runs it through the configured `node` binary as well, because PHP
* cannot open a woff2 without the Brotli extension.
*
* wght 400700 — every typescale weight (font.css `font-weight: 400 700`, type.css's regular,
* medium and bold reference tokens).
* ROND 0100 — the roundness axis every `type-emphasized-*` utility sets to 100. Re-subset
* the font without it and the emphasized styles quietly stop being round.
*
* Output is one JSON object on stdout — {file, postscriptName, numGlyphs, axes: {tag: {name, min,
* default, max}}} — and the exit status is 1, with the reason on stderr, when an axis is missing
* or narrower than the range above.
*/
import { openSync } from 'fontkit'
import { fileURLToPath } from 'node:url'
/** The axes the stylesheets depend on, and the range each one has to cover. */
const REQUIRED = {
wght: { min: 400, max: 700 },
ROND: { min: 0, max: 100 },
}
const file = process.argv[2] ?? fileURLToPath(new URL('../resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2', import.meta.url))
let font
try {
font = openSync(file)
} catch (error) {
process.stderr.write(`${file}: ${error.message}\n`)
process.exit(1)
}
const axes = font.variationAxes ?? {}
const problems = Object.entries(REQUIRED).flatMap(([tag, range]) => {
const axis = axes[tag]
if (!axis) {
return [`${tag} is missing; the subset has ${Object.keys(axes).join(', ') || 'no variable axes'}.`]
}
return axis.min > range.min || axis.max < range.max
? [`${tag} covers ${axis.min}${axis.max}, not the ${range.min}${range.max} the stylesheets ask for.`]
: []
})
process.stdout.write(`${JSON.stringify({
file,
postscriptName: font.postscriptName,
numGlyphs: font.numGlyphs,
axes,
})}\n`)
if (problems.length > 0) {
process.stderr.write(`${file}\n${problems.map((problem) => ` ${problem}`).join('\n')}\n`)
process.exit(1)
}
+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`)
}