An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
362 lines
16 KiB
JavaScript
362 lines
16 KiB
JavaScript
/**
|
||
* Regenerates resources/svg/loading-indicator: the Material 3 Expressive loading indicator.
|
||
*
|
||
* Run from the repository root with `npm run build:loading` (or `node bin/loading-indicator.mjs`).
|
||
* Maintenance only: plain Node 22+, no dependencies, and the output is deterministic, so running
|
||
* it twice changes nothing. The shape geometry comes from bin/shapes.mjs.
|
||
*
|
||
* indeterminate.svg the animated indicator: pure SVG + SMIL, no script, no ids. It is safe to
|
||
* inline any number of times on one page; there is no placeholder to replace.
|
||
* static.svg the first frame of that animation, for prefers-reduced-motion.
|
||
*
|
||
* What androidx's indeterminate LoadingIndicator draws, and how it is reproduced here:
|
||
*
|
||
* - Seven MaterialShapes morph into one another in a loop (SoftBurst, Cookie9Sided, Pentagon, Pill,
|
||
* Sunny, Cookie4Sided, Oval, back to SoftBurst). Every 650 ms a morph starts; its progress follows
|
||
* a spring (damping ratio 0.6, stiffness 200) that overshoots to ~1.095 at ~278 ms and settles.
|
||
* Each morph is androidx's `Morph` (feature-matched cubics, ported below), and since a morph is a
|
||
* plain lerp of matched control points, SMIL can interpolate it: every morph gets its own <path>
|
||
* whose `d` runs start → overshoot → end. Compose ends the spring at its 0.1 visibility threshold
|
||
* (~297 ms, still ~9% past the target) and snaps; here the same spring is followed to rest over
|
||
* the 650 ms instead, which removes that one-frame snap. The spring is fitted with two keySplines.
|
||
* - Each morph path repeats on a 4550 ms cycle that begins at its slot (650 ms × index). Until its
|
||
* slot it has no `d` (first cycle) or a point (later cycles), so it draws nothing. When its morph
|
||
* ends, the next path starts from the very same shape, and the finished path shrinks towards the
|
||
* centre — within one frame to about half its size, then on to a point. Every shape here is
|
||
* star-shaped about its centre, so a shrunken copy lies inside the shape the next morph starts
|
||
* from, and (checked while writing this) stays at least 0.18 units inside every later frame of
|
||
* it: the copy is always covered and never seen. No visibility switching, no ids, no seam.
|
||
* - The shape turns +90° per morph on the same spring (`morphRotationTargetAngle`, starting at 90°),
|
||
* a 2600 ms cycle of four morphs that ends where it began (450° = 90°), and the whole indicator
|
||
* turns 360° every 4666 ms, linearly. Nested rotations about the centre add up, as in Compose.
|
||
* - The shapes are sized as `calculateScaleFactor` does (so they look alike while rotating) and
|
||
* scaled by ActiveIndicatorScale (38 / 48) into the 48 × 48 container; like `processPath`, every
|
||
* drawn path is re-centred on its bounds. Compose takes the control-point bounds, which differ
|
||
* between two morphs' subdivisions of the same shape and nudge it by up to 0.18 units when one
|
||
* morph hands over to the next; the exact bounds used here are the same for both, so it holds still.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* Ported from androidx (https://github.com/androidx/androidx), commit
|
||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
||
*
|
||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt
|
||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/LoadingIndicatorTokens.kt
|
||
* compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/SpringSimulation.kt
|
||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt (calculateMaxBounds)
|
||
*
|
||
* The feature matching and Morph itself (FeatureMapping.kt, FloatMapping.kt, Morph.kt,
|
||
* PolygonMeasure.kt) are shared with progress.js: see resources/js/shapes.js.
|
||
*
|
||
* Copyright 2022-2024 The Android Open Source Project
|
||
*
|
||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||
* you may not use this file except in compliance with the License.
|
||
* You may obtain a copy of the License at
|
||
*
|
||
* http://www.apache.org/licenses/LICENSE-2.0
|
||
*
|
||
* Unless required by applicable law or agreed to in writing, software
|
||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
* See the License for the specific language governing permissions and
|
||
* limitations under the License.
|
||
* ---------------------------------------------------------------------------------------
|
||
*/
|
||
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import { SHAPES } from './shapes.mjs'
|
||
import { asCubics, cubicBounds, match, pointOnCurve } from '../resources/js/shapes.js'
|
||
|
||
const OUTPUT = 'resources/svg/loading-indicator'
|
||
|
||
// LoadingIndicator.kt / LoadingIndicatorTokens.kt --------------------------------------
|
||
|
||
const SEQUENCE = ['soft-burst', 'cookie-9', 'pentagon', 'pill', 'sunny', 'cookie-4', 'oval']
|
||
const CONTAINER = 48
|
||
const ACTIVE_SIZE = 38
|
||
const MORPH_INTERVAL = 650
|
||
const GLOBAL_ROTATION_DURATION = 4666
|
||
const QUARTER_ROTATION = 90
|
||
const SPRING = { dampingRatio: 0.6, stiffness: 200 }
|
||
|
||
/**
|
||
* How long (ms) before the next morph starts the finished path begins to shrink, so it is already
|
||
* inside the incoming shape and the two outlines do not coincide (which would draw anti-aliased
|
||
* edges twice). Chromium and WebKit solve keySplines only to about 1/(200 × dur), so there the
|
||
* shrink shows ~0.4 ms late; a frame in that window merely has slightly bolder edges. A longer lead
|
||
* would instead let the shape visibly dip before the next path takes over.
|
||
*/
|
||
const HANDOVER_LEAD = 0.001
|
||
|
||
/** The fastest collapse a keySpline can give: half the size within a frame, then a long tail. */
|
||
const COLLAPSE = [0, 1, 0, 1]
|
||
|
||
// LoadingIndicator.kt: calculateScaleFactor, processPath --------------------------------
|
||
|
||
/** RoundedPolygon.calculateMaxBounds: a square holding the shape in any rotation. */
|
||
function maxBounds(polygon) {
|
||
const { x, y } = polygon.center
|
||
let maxDistSquared = 0
|
||
|
||
for (const c of polygon.cubics) {
|
||
const middle = pointOnCurve(c, 0.5)
|
||
const anchorDistance = (c[0] - x) ** 2 + (c[1] - y) ** 2
|
||
const middleDistance = (middle.x - x) ** 2 + (middle.y - y) ** 2
|
||
maxDistSquared = Math.max(maxDistSquared, anchorDistance, middleDistance)
|
||
}
|
||
|
||
const distance = Math.sqrt(maxDistSquared)
|
||
|
||
return [x - distance, y - distance, x + distance, y + distance]
|
||
}
|
||
|
||
function calculateScaleFactor(polygons) {
|
||
let scaleFactor = 1
|
||
|
||
for (const polygon of polygons) {
|
||
const [left, top, right, bottom] = polygon.bounds(true)
|
||
const [maxLeft, maxTop, maxRight, maxBottom] = maxBounds(polygon)
|
||
const scaleX = (right - left) / (maxRight - maxLeft)
|
||
const scaleY = (bottom - top) / (maxBottom - maxTop)
|
||
scaleFactor = Math.min(scaleFactor, Math.max(scaleX, scaleY))
|
||
}
|
||
|
||
return scaleFactor
|
||
}
|
||
|
||
/** processPath: scales the normalised cubics into the container and centres their exact bounds. */
|
||
function processPath(cubics, scale) {
|
||
const scaled = cubics.map((c) => c.map((value) => value * scale))
|
||
const bounds = scaled.map((c) => cubicBounds(c, false))
|
||
const dx = CONTAINER / 2 - (Math.min(...bounds.map((b) => b[0])) + Math.max(...bounds.map((b) => b[2]))) / 2
|
||
const dy = CONTAINER / 2 - (Math.min(...bounds.map((b) => b[1])) + Math.max(...bounds.map((b) => b[3]))) / 2
|
||
|
||
return scaled.map((c) => c.map((value, i) => value + (i % 2 === 0 ? dx : dy)))
|
||
}
|
||
|
||
/** DrawScope.rotate about the container centre, clockwise on screen. */
|
||
function rotated(cubics, degrees) {
|
||
const r = degrees * (Math.PI / 180)
|
||
const [s, c, o] = [Math.sin(r), Math.cos(r), CONTAINER / 2]
|
||
|
||
return cubics.map((cubic) =>
|
||
cubic.map((value, i) => {
|
||
const [x, y] = i % 2 === 0 ? [value - o, cubic[i + 1] - o] : [cubic[i - 1] - o, value - o]
|
||
|
||
return i % 2 === 0 ? o + c * x - s * y : o + s * x + c * y
|
||
}),
|
||
)
|
||
}
|
||
|
||
// SpringSimulation.kt and the keySplines that stand in for it ---------------------------
|
||
|
||
/** The spring's value `ms` after it starts from 0 at rest towards 1 (the underdamped branch of updateValues). */
|
||
function spring(ms) {
|
||
const naturalFreq = Math.sqrt(SPRING.stiffness)
|
||
const r = -SPRING.dampingRatio * naturalFreq
|
||
const dampedFreq = naturalFreq * Math.sqrt(1 - SPRING.dampingRatio ** 2)
|
||
const t = ms / 1000
|
||
|
||
return 1 + Math.exp(r * t) * (-Math.cos(dampedFreq * t) + ((-r * -1) / dampedFreq) * Math.sin(dampedFreq * t))
|
||
}
|
||
|
||
/** When the spring peaks: half a period of its damped oscillation. */
|
||
const PEAK_TIME = (Math.PI / (Math.sqrt(SPRING.stiffness) * Math.sqrt(1 - SPRING.dampingRatio ** 2))) * 1000
|
||
const PEAK = spring(PEAK_TIME)
|
||
|
||
function bezier(p1, p2, s) {
|
||
const u = 1 - s
|
||
|
||
return 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s
|
||
}
|
||
|
||
/** A keySpline's output at input `x`, finding the curve parameter by bisection (x is monotonic in it). */
|
||
function keySpline([x1, y1, x2, y2], x) {
|
||
let lo = 0
|
||
let hi = 1
|
||
|
||
for (let i = 0; i < 50; i++) {
|
||
const mid = (lo + hi) / 2
|
||
|
||
if (bezier(x1, x2, mid) < x) {
|
||
lo = mid
|
||
} else {
|
||
hi = mid
|
||
}
|
||
}
|
||
|
||
return bezier(y1, y2, (lo + hi) / 2)
|
||
}
|
||
|
||
/**
|
||
* Least-squares keySpline for one stretch of the spring, mapped onto 0–1 in time and value, by a
|
||
* deterministic pattern search over the four control values (all kept in 0–1, as SMIL requires).
|
||
*/
|
||
function fitKeySpline(fromMs, toMs, fromValue, toValue) {
|
||
const samples = Array.from({ length: 101 }, (_, i) => {
|
||
const x = i / 100
|
||
|
||
return [x, (spring(fromMs + x * (toMs - fromMs)) - fromValue) / (toValue - fromValue)]
|
||
})
|
||
const error = (spline) => samples.reduce((sum, [x, y]) => sum + (keySpline(spline, x) - y) ** 2, 0)
|
||
let spline = [1 / 3, 1 / 3, 2 / 3, 2 / 3]
|
||
let best = error(spline)
|
||
|
||
for (let step = 0.25; step > 1e-5; step /= 2) {
|
||
let improved = true
|
||
|
||
while (improved) {
|
||
improved = false
|
||
|
||
for (let i = 0; i < 4; i++) {
|
||
for (const delta of [step, -step]) {
|
||
const candidate = spline.with(i, Math.min(1, Math.max(0, spline[i] + delta)))
|
||
const candidateError = error(candidate)
|
||
|
||
if (candidateError < best - 1e-12) {
|
||
spline = candidate
|
||
best = candidateError
|
||
improved = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return spline
|
||
}
|
||
|
||
// SVG -----------------------------------------------------------------------------------
|
||
|
||
/** Precision of every coordinate: hundredths of a container unit. */
|
||
const PRECISION = 100
|
||
|
||
function decimal(value, places) {
|
||
return String(Number(value.toFixed(places))).replace(/^(-?)0\./, '$1.')
|
||
}
|
||
|
||
/** Joins numbers, leaving out the separator where a sign or a second decimal point already splits them. */
|
||
function joinNumbers(numbers) {
|
||
let out = ''
|
||
|
||
for (const text of numbers) {
|
||
const previous = out.slice(out.lastIndexOf(' ') + 1)
|
||
|
||
if (out === '' || text.startsWith('-') || (text.startsWith('.') && previous.includes('.'))) {
|
||
out += text
|
||
} else {
|
||
out += ` ${text}`
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Path data in hundredths relative to the current point, rounded as absolute positions first so the
|
||
* rounding never drifts along the outline. `format` writes one count of hundredths.
|
||
*/
|
||
function pathData(cubics, format) {
|
||
const points = cubics.map((c) => c.map((value) => Math.round(value * PRECISION)))
|
||
const numbers = []
|
||
let [x, y] = [points[0][0], points[0][1]]
|
||
|
||
for (const c of points) {
|
||
numbers.push(c[2] - x, c[3] - y, c[4] - x, c[5] - y, c[6] - x, c[7] - y)
|
||
;[x, y] = [c[6], c[7]]
|
||
}
|
||
|
||
return `M${joinNumbers([points[0][0], points[0][1]].map(format))}c${joinNumbers(numbers.map(format))}Z`
|
||
}
|
||
|
||
/** Hundredths as decimals, for a path drawn in container units. */
|
||
const decimalHundredths = (n) => decimal(n / PRECISION, 2)
|
||
|
||
/**
|
||
* Hundredths as integers, for a path drawn inside `scale(.01)`: the same precision as two decimals,
|
||
* and about a sixth shorter.
|
||
*/
|
||
const integerHundredths = (n) => String(n)
|
||
|
||
/** The same command structure as a path of `count` cubics, collapsed onto the container centre. */
|
||
function collapsedPathData(count) {
|
||
const centre = (CONTAINER / 2) * PRECISION
|
||
|
||
return `M${centre} ${centre}c${Array(count * 6).fill(0).join(' ')}Z`
|
||
}
|
||
|
||
const time = (value) => decimal(value, 6)
|
||
const splineText = (spline) => spline.map((value) => decimal(value, 3)).join(' ')
|
||
|
||
const polygons = SEQUENCE.map((name) => SHAPES[name]().normalized())
|
||
const scale = CONTAINER * calculateScaleFactor(polygons) * (ACTIVE_SIZE / CONTAINER)
|
||
const rise = fitKeySpline(0, PEAK_TIME, 0, PEAK)
|
||
const settle = fitKeySpline(PEAK_TIME, MORPH_INTERVAL, PEAK, 1)
|
||
const cycle = MORPH_INTERVAL * SEQUENCE.length
|
||
const riseTime = time(PEAK_TIME / cycle)
|
||
const collapseTime = decimal(Math.floor(((MORPH_INTERVAL - HANDOVER_LEAD) / cycle) * 1e7) / 1e7, 7)
|
||
|
||
const morphPaths = SEQUENCE.map((_, index) => {
|
||
const pairs = match(polygons[index], polygons[(index + 1) % SEQUENCE.length])
|
||
const values = [0, PEAK, 1].map((progress) =>
|
||
pathData(processPath(asCubics(pairs, progress), scale), integerHundredths),
|
||
)
|
||
|
||
return (
|
||
`<path><animate attributeName="d" begin="${MORPH_INTERVAL * index}ms" dur="${cycle}ms" repeatCount="indefinite" ` +
|
||
`calcMode="spline" keyTimes="0;${riseTime};${collapseTime};1" ` +
|
||
`keySplines="${splineText(rise)};${splineText(settle)};${splineText(COLLAPSE)}" ` +
|
||
`values="${[...values, collapsedPathData(pairs.length)].join(';')}"/></path>`
|
||
)
|
||
})
|
||
|
||
/** Four morphs of +90° bring the shape back to its starting angle. */
|
||
const rotationMorphs = 4
|
||
const rotationKeyTimes = []
|
||
const rotationValues = []
|
||
|
||
for (let i = 0; i < rotationMorphs; i++) {
|
||
const angle = QUARTER_ROTATION * (i + 1)
|
||
rotationKeyTimes.push(i / rotationMorphs, (i + PEAK_TIME / MORPH_INTERVAL) / rotationMorphs)
|
||
rotationValues.push(angle, angle + QUARTER_ROTATION * PEAK)
|
||
}
|
||
|
||
rotationKeyTimes.push(1)
|
||
rotationValues.push(QUARTER_ROTATION * (rotationMorphs + 1))
|
||
|
||
const centre = `${CONTAINER / 2} ${CONTAINER / 2}`
|
||
const morphRotation =
|
||
`<animateTransform attributeName="transform" type="rotate" dur="${MORPH_INTERVAL * rotationMorphs}ms" repeatCount="indefinite" ` +
|
||
`calcMode="spline" keyTimes="${rotationKeyTimes.map(time).join(';')}" ` +
|
||
`keySplines="${Array(rotationMorphs).fill(`${splineText(rise)};${splineText(settle)}`).join(';')}" ` +
|
||
`values="${rotationValues.map((angle) => `${decimal(angle, 3)} ${centre}`).join(';')}"/>`
|
||
const globalRotation =
|
||
`<animateTransform attributeName="transform" type="rotate" from="0 ${centre}" to="360 ${centre}" ` +
|
||
`dur="${GLOBAL_ROTATION_DURATION}ms" repeatCount="indefinite"/>`
|
||
const firstFrame = rotated(processPath(polygons[0].cubics, scale), QUARTER_ROTATION)
|
||
|
||
const open = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CONTAINER} ${CONTAINER}" fill="currentColor">`
|
||
const indicator = `<g transform="scale(${decimal(1 / PRECISION, 2)})">${morphPaths.join('')}</g>`
|
||
const files = {
|
||
'indeterminate.svg': `${open}<g>${globalRotation}<g>${morphRotation}${indicator}</g></g></svg>\n`,
|
||
'static.svg': `${open}<path d="${pathData(firstFrame, decimalHundredths)}"/></svg>\n`,
|
||
}
|
||
|
||
mkdirSync(OUTPUT, { recursive: true })
|
||
|
||
for (const file of readdirSync(OUTPUT)) {
|
||
if (file.endsWith('.svg')) {
|
||
rmSync(join(OUTPUT, file))
|
||
}
|
||
}
|
||
|
||
for (const [name, svg] of Object.entries(files)) {
|
||
writeFileSync(join(OUTPUT, name), svg)
|
||
}
|
||
|
||
console.log(
|
||
`Wrote ${Object.entries(files)
|
||
.map(([name, svg]) => `${name} (${Buffer.byteLength(svg)} bytes)`)
|
||
.join(', ')} to ${OUTPUT}`,
|
||
)
|