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>
115 lines
4.1 KiB
JavaScript
115 lines
4.1 KiB
JavaScript
/**
|
||
* `x-figure`: a figure counts to its value — from zero when it first appears, and from what
|
||
* it said before when the server changes it.
|
||
*
|
||
* The server renders the real value, so without JavaScript, in a test or in a screenshot the
|
||
* figure is simply right. Only a figure with exactly one number in it moves — "39.6",
|
||
* "1,204 km", "CHF 12.50" — and a time, a date or a range is left alone rather than counted
|
||
* as if it were one quantity. The format is the target's: its decimals and its thousands
|
||
* separator, so a column of counting figures keeps its width under `tabular-nums`.
|
||
*
|
||
* The curve decelerates and never overshoots, unlike the spatial springs, because a figure
|
||
* that passes its value is briefly a lie. The duration is the spatial-slow token's, which
|
||
* reduced motion sets to zero — and then the figure is written once, as it is.
|
||
*
|
||
* A re-render that leaves the text the same never reaches the observer, so a Livewire round
|
||
* trip does not replay it; one that changes it counts from the figure on screen, even
|
||
* mid-count. The observer ignores the directive's own writes by comparing against the last
|
||
* thing it wrote.
|
||
*/
|
||
import { ms } from './util.js'
|
||
|
||
const FIGURE = /[-−]?\d{1,3}(?:[,'’]\d{3})+(?:\.\d+)?|[-−]?\d+(?:\.\d+)?/g
|
||
|
||
const readFigure = (text) => {
|
||
const matches = [...text.matchAll(FIGURE)]
|
||
|
||
if (matches.length !== 1) {
|
||
return null
|
||
}
|
||
|
||
const [raw] = matches[0]
|
||
|
||
return {
|
||
value: Number(raw.replace(/[,'’]/g, '').replace('−', '-')),
|
||
decimals: raw.includes('.') ? raw.split('.')[1].length : 0,
|
||
separator: raw.match(/\d([,'’])\d{3}/)?.[1] ?? '',
|
||
minus: raw.startsWith('−') ? '−' : '-',
|
||
before: text.slice(0, matches[0].index),
|
||
after: text.slice(matches[0].index + raw.length),
|
||
}
|
||
}
|
||
|
||
const writeFigure = (figure, value) => {
|
||
let [whole, fraction] = Math.abs(value).toFixed(figure.decimals).split('.')
|
||
|
||
if (figure.separator) {
|
||
whole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, figure.separator)
|
||
}
|
||
|
||
const sign = value < 0 && Number(Math.abs(value).toFixed(figure.decimals)) !== 0 ? figure.minus : ''
|
||
|
||
return figure.before + sign + whole + (fraction ? `.${fraction}` : '') + figure.after
|
||
}
|
||
|
||
document.addEventListener('alpine:init', () => {
|
||
window.Alpine.directive('figure', (el, _, { cleanup }) => {
|
||
const text = () => (el.childNodes.length === 1 && el.firstChild.nodeType === Node.TEXT_NODE ? el.firstChild : null)
|
||
|
||
let written = text()?.data ?? null
|
||
let frame = null
|
||
|
||
const count = (from, target) => {
|
||
cancelAnimationFrame(frame)
|
||
|
||
const node = text()
|
||
const length = ms(document.documentElement, '--md-sys-motion-spatial-slow-duration') ?? 0
|
||
|
||
if (!node || !target || length === 0 || from === target.value) {
|
||
return
|
||
}
|
||
|
||
const started = performance.now()
|
||
|
||
const step = (now) => {
|
||
const progress = Math.min(1, (now - started) / length)
|
||
const eased = 1 - Math.pow(1 - progress, 4)
|
||
|
||
written = progress === 1 ? writeFigure(target, target.value) : writeFigure(target, from + (target.value - from) * eased)
|
||
node.data = written
|
||
|
||
if (progress < 1) {
|
||
frame = requestAnimationFrame(step)
|
||
}
|
||
}
|
||
|
||
written = writeFigure(target, from)
|
||
node.data = written
|
||
frame = requestAnimationFrame(step)
|
||
}
|
||
|
||
const observer = new MutationObserver(() => {
|
||
const node = text()
|
||
|
||
if (!node || node.data === written) {
|
||
return
|
||
}
|
||
|
||
const target = readFigure(node.data)
|
||
const shown = written === null ? null : readFigure(written)
|
||
|
||
written = node.data
|
||
count(shown?.value ?? 0, target)
|
||
})
|
||
|
||
observer.observe(el, { characterData: true, childList: true, subtree: true })
|
||
|
||
count(0, written === null ? null : readFigure(written))
|
||
|
||
cleanup(() => {
|
||
cancelAnimationFrame(frame)
|
||
observer.disconnect()
|
||
})
|
||
})
|
||
})
|