Merge branch 'worktree-agent-afa50210f14592c5f'
This commit is contained in:
@@ -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 400–700 — every typescale weight (font.css `font-weight: 400 700`, type.css's regular,
|
||||
* medium and bold reference tokens).
|
||||
* ROND 0–100 — 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
@@ -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`)
|
||||
}
|
||||
Reference in New Issue
Block a user