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>
408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
/**
|
||
* Regenerates resources/svg/shapes: one SVG per Material 3 Expressive shape.
|
||
*
|
||
* Run from the repository root with `npm run build:shapes` (or `node bin/shapes.mjs`).
|
||
* Maintenance only: plain Node 22+, no dependencies, and the output is deterministic,
|
||
* so running it twice changes nothing.
|
||
*
|
||
* The geometry is not traced but ported from androidx, which defines every shape as a
|
||
* RoundedPolygon — vertices plus per-vertex CornerRounding — and turns it into cubic
|
||
* Béziers. That construction is reproduced here line for line (with doubles in place of
|
||
* Kotlin's Floats), then each shape is normalised as MaterialShapes does, scaled so its
|
||
* exact bounds span 96 units on the larger axis, and centred in a 100 × 100 viewBox.
|
||
* Where a control point would still land outside the viewBox, that cubic is split into
|
||
* pieces of the same curve (see `contained`), so every coordinate in a file lies in 0–100.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* Ported from androidx (https://github.com/androidx/androidx), commit
|
||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
||
*
|
||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MaterialShapes.kt
|
||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
|
||
* compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Matrix.kt (rotateZ, scale)
|
||
*
|
||
* The RoundedPolygon construction itself (CornerRounding, Cubic, Point, RoundedPolygon, Shapes,
|
||
* Utils) is shared with bin/loading-indicator.mjs and 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, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { circlePolygon, length, minus, plus, point, polygonFromVertices, regularPolygon, rounding, split, star, times, turningPoints, UNROUNDED } from '../resources/js/shapes.js'
|
||
|
||
const OUTPUT = 'resources/svg/shapes'
|
||
const VIEWBOX = 100
|
||
const FILL = 96
|
||
|
||
// Shapes.kt -----------------------------------------------------------------------------
|
||
|
||
function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
|
||
const [left, top, right, bottom] = [-width / 2, -height / 2, width / 2, height / 2]
|
||
|
||
return polygonFromVertices([point(right, bottom), point(left, bottom), point(left, top), point(right, top)], {
|
||
cornerRounding,
|
||
perVertexRounding,
|
||
center: point(0, 0),
|
||
})
|
||
}
|
||
|
||
// Matrix.kt (rotateZ, scale) and ShapeUtil.kt (RoundedPolygon.transformed(Matrix)) --------
|
||
|
||
function rotateZ(degrees) {
|
||
const r = degrees * (Math.PI / 180)
|
||
const s = Math.sin(r)
|
||
const c = Math.cos(r)
|
||
|
||
return (x, y) => point(c * x - s * y, s * x + c * y)
|
||
}
|
||
|
||
const scale = (sx, sy) => (x, y) => point(x * sx, y * sy)
|
||
|
||
// MaterialShapes.kt ---------------------------------------------------------------------
|
||
|
||
const cornerRound15 = rounding(0.15)
|
||
const cornerRound20 = rounding(0.2)
|
||
const cornerRound30 = rounding(0.3)
|
||
const cornerRound50 = rounding(0.5)
|
||
const cornerRound100 = rounding(1)
|
||
|
||
const rotateNeg45 = rotateZ(-45)
|
||
const rotateNeg90 = rotateZ(-90)
|
||
const rotateNeg135 = rotateZ(-135)
|
||
|
||
/** PointNRound: a vertex of a custom polygon and its rounding. */
|
||
const pnr = (x, y, cornerRounding = UNROUNDED) => ({ o: point(x, y), r: cornerRounding })
|
||
|
||
const toRadians = (degrees) => (degrees / 360) * 2 * Math.PI
|
||
const angleDegrees = (p) => (Math.atan2(p.y, p.x) * 180) / Math.PI
|
||
|
||
function rotateDegrees(p, angle, center) {
|
||
const a = toRadians(angle)
|
||
const off = minus(p, center)
|
||
|
||
return plus(point(off.x * Math.cos(a) - off.y * Math.sin(a), off.x * Math.sin(a) + off.y * Math.cos(a)), center)
|
||
}
|
||
|
||
function doRepeat(points, reps, center, mirroring) {
|
||
if (!mirroring) {
|
||
const np = points.length
|
||
|
||
return Array.from({ length: np * reps }, (_, it) => ({
|
||
o: rotateDegrees(points[it % np].o, (Math.floor(it / np) * 360) / reps, center),
|
||
r: points[it % np].r,
|
||
}))
|
||
}
|
||
|
||
const angles = points.map((p) => angleDegrees(minus(p.o, center)))
|
||
const distances = points.map((p) => length(minus(p.o, center)))
|
||
const actualReps = reps * 2
|
||
const sectionAngle = 360 / actualReps
|
||
const out = []
|
||
|
||
for (let it = 0; it < actualReps; it++) {
|
||
for (let index = 0; index < points.length; index++) {
|
||
const i = it % 2 === 0 ? index : points.length - 1 - index
|
||
|
||
if (i > 0 || it % 2 === 0) {
|
||
const a = toRadians(
|
||
sectionAngle * it + (it % 2 === 0 ? angles[i] : sectionAngle - angles[i] + 2 * angles[0]),
|
||
)
|
||
|
||
out.push({ o: plus(times(point(Math.cos(a), Math.sin(a)), distances[i]), center), r: points[i].r })
|
||
}
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
function customPolygon(points, reps, { center = point(0.5, 0.5), mirroring = false } = {}) {
|
||
const actualPoints = doRepeat(points, reps, center, mirroring)
|
||
|
||
return polygonFromVertices(
|
||
actualPoints.map((p) => p.o),
|
||
{ perVertexRounding: actualPoints.map((p) => p.r), center },
|
||
)
|
||
}
|
||
|
||
/** File name → the unnormalised shape, in MaterialShapes' declaration order. */
|
||
const SHAPES = {
|
||
circle: () => circlePolygon(10),
|
||
square: () => rectangle({ width: 1, height: 1, cornerRounding: cornerRound30 }),
|
||
slanted: () => customPolygon([pnr(0.926, 0.97, rounding(0.189, 0.811)), pnr(-0.021, 0.967, rounding(0.187, 0.057))], 2),
|
||
arch: () =>
|
||
regularPolygon(4, {
|
||
perVertexRounding: [cornerRound100, cornerRound100, cornerRound20, cornerRound20],
|
||
}).transformed(rotateNeg135),
|
||
fan: () =>
|
||
customPolygon(
|
||
[
|
||
pnr(1.004, 1.0, rounding(0.148, 0.417)),
|
||
pnr(0.0, 1.0, rounding(0.151)),
|
||
pnr(0.0, -0.003, rounding(0.148)),
|
||
pnr(0.978, 0.02, rounding(0.803)),
|
||
],
|
||
1,
|
||
),
|
||
arrow: () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.5, 0.892, rounding(0.313)),
|
||
pnr(-0.216, 1.05, rounding(0.207)),
|
||
pnr(0.499, -0.16, rounding(0.215, 1.0)),
|
||
pnr(1.225, 1.06, rounding(0.211)),
|
||
],
|
||
1,
|
||
),
|
||
'semi-circle': () =>
|
||
rectangle({
|
||
width: 1.6,
|
||
height: 1,
|
||
perVertexRounding: [cornerRound20, cornerRound20, cornerRound100, cornerRound100],
|
||
}),
|
||
oval: () => circlePolygon().transformed(scale(1, 0.64)).transformed(rotateNeg45),
|
||
pill: () =>
|
||
customPolygon([pnr(0.961, 0.039, rounding(0.426)), pnr(1.001, 0.428), pnr(1.0, 0.609, rounding(1.0))], 2, {
|
||
mirroring: true,
|
||
}),
|
||
triangle: () => regularPolygon(3, { cornerRounding: cornerRound20 }).transformed(rotateNeg90),
|
||
diamond: () => customPolygon([pnr(0.5, 1.096, rounding(0.151, 0.524)), pnr(0.04, 0.5, rounding(0.159))], 2),
|
||
'clam-shell': () =>
|
||
customPolygon(
|
||
[pnr(0.171, 0.841, rounding(0.159)), pnr(-0.02, 0.5, rounding(0.14)), pnr(0.17, 0.159, rounding(0.159))],
|
||
2,
|
||
),
|
||
pentagon: () =>
|
||
customPolygon(
|
||
[pnr(0.5, -0.009, rounding(0.172)), pnr(1.03, 0.365, rounding(0.164)), pnr(0.828, 0.97, rounding(0.169))],
|
||
1,
|
||
{ mirroring: true },
|
||
),
|
||
gem: () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.499, 1.023, rounding(0.241, 0.778)),
|
||
pnr(-0.005, 0.792, rounding(0.208)),
|
||
pnr(0.073, 0.258, rounding(0.228)),
|
||
pnr(0.433, -0.0, rounding(0.491)),
|
||
],
|
||
1,
|
||
{ mirroring: true },
|
||
),
|
||
'very-sunny': () => customPolygon([pnr(0.5, 1.08, rounding(0.085)), pnr(0.358, 0.843, rounding(0.085))], 8),
|
||
sunny: () => star(8, { innerRadius: 0.8, cornerRounding: cornerRound15 }),
|
||
'cookie-4': () => customPolygon([pnr(1.237, 1.236, rounding(0.258)), pnr(0.5, 0.918, rounding(0.233))], 4),
|
||
'cookie-6': () => customPolygon([pnr(0.723, 0.884, rounding(0.394)), pnr(0.5, 1.099, rounding(0.398))], 6),
|
||
'cookie-7': () => star(7, { innerRadius: 0.75, cornerRounding: cornerRound50 }).transformed(rotateNeg90),
|
||
'cookie-9': () => star(9, { innerRadius: 0.8, cornerRounding: cornerRound50 }).transformed(rotateNeg90),
|
||
'cookie-12': () => star(12, { innerRadius: 0.8, cornerRounding: cornerRound50 }).transformed(rotateNeg90),
|
||
ghostish: () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.5, 0, rounding(1.0)),
|
||
pnr(1, 0, rounding(1.0)),
|
||
pnr(1, 1.14, rounding(0.254, 0.106)),
|
||
pnr(0.575, 0.906, rounding(0.253)),
|
||
],
|
||
1,
|
||
{ mirroring: true },
|
||
),
|
||
'clover-4': () => customPolygon([pnr(0.5, 0.074), pnr(0.725, -0.099, rounding(0.476))], 4, { mirroring: true }),
|
||
'clover-8': () => customPolygon([pnr(0.5, 0.036), pnr(0.758, -0.101, rounding(0.209))], 8),
|
||
burst: () => customPolygon([pnr(0.5, -0.006, rounding(0.006)), pnr(0.592, 0.158, rounding(0.006))], 12),
|
||
'soft-burst': () => customPolygon([pnr(0.193, 0.277, rounding(0.053)), pnr(0.176, 0.055, rounding(0.053))], 10),
|
||
boom: () => customPolygon([pnr(0.457, 0.296, rounding(0.007)), pnr(0.5, -0.051, rounding(0.007))], 15),
|
||
'soft-boom': () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.733, 0.454),
|
||
pnr(0.839, 0.437, rounding(0.532)),
|
||
pnr(0.949, 0.449, rounding(0.439, 1.0)),
|
||
pnr(0.998, 0.478, rounding(0.174)),
|
||
],
|
||
16,
|
||
{ mirroring: true },
|
||
),
|
||
flower: () =>
|
||
customPolygon([pnr(0.37, 0.187), pnr(0.416, 0.049, rounding(0.381)), pnr(0.479, 0.001, rounding(0.095))], 8, {
|
||
mirroring: true,
|
||
}),
|
||
puffy: () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.5, 0.053),
|
||
pnr(0.545, -0.04, rounding(0.405)),
|
||
pnr(0.67, -0.035, rounding(0.426)),
|
||
pnr(0.717, 0.066, rounding(0.574)),
|
||
pnr(0.722, 0.128),
|
||
pnr(0.777, 0.002, rounding(0.36)),
|
||
pnr(0.914, 0.149, rounding(0.66)),
|
||
pnr(0.926, 0.289, rounding(0.66)),
|
||
pnr(0.881, 0.346),
|
||
pnr(0.94, 0.344, rounding(0.126)),
|
||
pnr(1.003, 0.437, rounding(0.255)),
|
||
],
|
||
2,
|
||
{ mirroring: true },
|
||
).transformed(scale(1, 0.742)),
|
||
'puffy-diamond': () =>
|
||
customPolygon([pnr(0.87, 0.13, rounding(0.146)), pnr(0.818, 0.357), pnr(1.0, 0.332, rounding(0.853))], 4, {
|
||
mirroring: true,
|
||
}),
|
||
'pixel-circle': () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.5, 0.0),
|
||
pnr(0.704, 0.0),
|
||
pnr(0.704, 0.065),
|
||
pnr(0.843, 0.065),
|
||
pnr(0.843, 0.148),
|
||
pnr(0.926, 0.148),
|
||
pnr(0.926, 0.296),
|
||
pnr(1.0, 0.296),
|
||
],
|
||
2,
|
||
{ mirroring: true },
|
||
),
|
||
'pixel-triangle': () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.11, 0.5),
|
||
pnr(0.113, 0.0),
|
||
pnr(0.287, 0.0),
|
||
pnr(0.287, 0.087),
|
||
pnr(0.421, 0.087),
|
||
pnr(0.421, 0.17),
|
||
pnr(0.56, 0.17),
|
||
pnr(0.56, 0.265),
|
||
pnr(0.674, 0.265),
|
||
pnr(0.675, 0.344),
|
||
pnr(0.789, 0.344),
|
||
pnr(0.789, 0.439),
|
||
pnr(0.888, 0.439),
|
||
],
|
||
1,
|
||
{ mirroring: true },
|
||
),
|
||
bun: () =>
|
||
customPolygon(
|
||
[pnr(0.796, 0.5), pnr(0.853, 0.518, rounding(1)), pnr(0.992, 0.631, rounding(1)), pnr(0.968, 1.0, rounding(1))],
|
||
2,
|
||
{ mirroring: true },
|
||
),
|
||
heart: () =>
|
||
customPolygon(
|
||
[
|
||
pnr(0.5, 0.268, rounding(0.016)),
|
||
pnr(0.792, -0.066, rounding(0.958)),
|
||
pnr(1.064, 0.276, rounding(1.0)),
|
||
pnr(0.501, 0.946, rounding(0.129)),
|
||
],
|
||
1,
|
||
{ mirroring: true },
|
||
),
|
||
}
|
||
|
||
// SVG -----------------------------------------------------------------------------------
|
||
|
||
/** Scales the normalised shape so its exact bounds span FILL on the larger axis, centred in the viewBox. */
|
||
function fitted(polygon) {
|
||
const [left, top, right, bottom] = polygon.bounds(false)
|
||
const k = FILL / Math.max(right - left, bottom - top)
|
||
const dx = VIEWBOX / 2 - ((left + right) / 2) * k
|
||
const dy = VIEWBOX / 2 - ((top + bottom) / 2) * k
|
||
|
||
return polygon.transformed((x, y) => point(x * k + dx, y * k + dy))
|
||
}
|
||
|
||
const round = (value) => Math.round(value * 10) / 10
|
||
|
||
function number(value) {
|
||
const rounded = round(value)
|
||
|
||
return String(Object.is(rounded, -0) ? 0 : rounded)
|
||
}
|
||
|
||
/**
|
||
* A cubic's curve stays inside the viewBox, but its control points can reach past it where a
|
||
* tight corner bulges towards the edge. Split such a cubic at its turning points (halving when it
|
||
* has none) until every control point is inside too: the same curve, drawn in more pieces.
|
||
*/
|
||
function contained(c, depth = 0) {
|
||
const outside = c.some((value) => round(value) < 0 || round(value) > VIEWBOX)
|
||
|
||
if (!outside || depth > 8) {
|
||
return [c]
|
||
}
|
||
|
||
const ts = [...new Set([...turningPoints(c, 0), ...turningPoints(c, 1)])]
|
||
.filter((t) => t > 1e-3 && t < 1 - 1e-3)
|
||
.sort((a, b) => a - b)
|
||
const cuts = ts.length > 0 ? ts : [0.5]
|
||
const pieces = []
|
||
let rest = c
|
||
let consumed = 0
|
||
|
||
for (const t of cuts) {
|
||
const [head, tail] = split(rest, (t - consumed) / (1 - consumed))
|
||
pieces.push(head)
|
||
rest = tail
|
||
consumed = t
|
||
}
|
||
|
||
pieces.push(rest)
|
||
|
||
return pieces.flatMap((piece) => contained(piece, depth + 1))
|
||
}
|
||
|
||
function pathData(polygon) {
|
||
const cubics = polygon.cubics.flatMap((c) => contained(c)).map((c) => c.map(number))
|
||
let d = `M${cubics[0][0]} ${cubics[0][1]}`
|
||
|
||
for (const c of cubics) {
|
||
// A corner rounded by a few thousandths collapses to a point at one decimal: skip it.
|
||
if (c.every((value, i) => value === c[i % 2])) {
|
||
continue
|
||
}
|
||
|
||
d += `C${c.slice(2).join(' ')}`
|
||
}
|
||
|
||
return `${d}Z`
|
||
}
|
||
|
||
/** The shape catalog, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
|
||
export { SHAPES }
|
||
|
||
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
mkdirSync(OUTPUT, { recursive: true })
|
||
|
||
for (const file of readdirSync(OUTPUT)) {
|
||
if (file.endsWith('.svg')) {
|
||
rmSync(join(OUTPUT, file))
|
||
}
|
||
}
|
||
|
||
for (const [name, build] of Object.entries(SHAPES)) {
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" fill="currentColor"><path d="${pathData(fitted(build().normalized()))}"/></svg>\n`
|
||
|
||
writeFileSync(join(OUTPUT, `${name}.svg`), svg)
|
||
}
|
||
|
||
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)
|
||
}
|