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>
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
/**
|
|
* The source of resources/node/scheme.mjs, which `php artisan material:scheme` runs.
|
|
*
|
|
* Rebuild with `npm run build:scheme` after changing this file or upgrading
|
|
* @material/material-color-utilities; the bundle is committed so applications need
|
|
* nothing but `node`. (The published library imports without file extensions, which
|
|
* plain Node refuses, so it cannot be run unbundled anyway — and two of the classes
|
|
* below are imported by path, which only a bundler resolves.)
|
|
*
|
|
* Input: one JSON argument — {seed, variant, spec, contrast, harmonize, success, warning,
|
|
* info}. `spec` is the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it
|
|
* first shipped). `contrast` is the standard level, below M3's medium (0.5).
|
|
* Output: JSON on stdout — {seed, variant, spec, harmonize, contrast: {standard, medium:
|
|
* {light, dark}, high: {light, dark}}, light: {role: hex}, dark: {role: hex}}. The top-level
|
|
* light and dark are the standard level; medium (0.5) and high (1.0) are M3's other two
|
|
* contrast levels, generated for both themes so a stylesheet can key them on an attribute.
|
|
*/
|
|
import {
|
|
argbFromHex,
|
|
Blend,
|
|
clampDouble,
|
|
DynamicColor,
|
|
extendSpecVersion,
|
|
hexFromArgb,
|
|
Hct,
|
|
MaterialDynamicColors,
|
|
SchemeContent,
|
|
SchemeExpressive,
|
|
SchemeFidelity,
|
|
SchemeFruitSalad,
|
|
SchemeMonochrome,
|
|
SchemeNeutral,
|
|
SchemeRainbow,
|
|
SchemeTonalSpot,
|
|
SchemeVibrant,
|
|
TonalPalette,
|
|
Variant,
|
|
} from '@material/material-color-utilities'
|
|
// Google exports neither from the package's index; the bundler resolves the path.
|
|
import { ContrastCurve } from '../node_modules/@material/material-color-utilities/dynamiccolor/contrast_curve.js'
|
|
import { ToneDeltaPair } from '../node_modules/@material/material-color-utilities/dynamiccolor/tone_delta_pair.js'
|
|
|
|
const VARIANTS = {
|
|
'tonal-spot': SchemeTonalSpot,
|
|
vibrant: SchemeVibrant,
|
|
expressive: SchemeExpressive,
|
|
neutral: SchemeNeutral,
|
|
fidelity: SchemeFidelity,
|
|
content: SchemeContent,
|
|
monochrome: SchemeMonochrome,
|
|
rainbow: SchemeRainbow,
|
|
'fruit-salad': SchemeFruitSalad,
|
|
}
|
|
|
|
// M3's three contrast levels (styles/color/roles, "What's new May 2025"): standard is the
|
|
// scheme's own level, medium and high are fixed. Every level is generated for both themes.
|
|
const LEVELS = { standard: 0, medium: 0.5, high: 1 }
|
|
|
|
// Key colours seed the palettes; they are not roles a stylesheet paints with.
|
|
const NOT_ROLES = /_palette_key_color$/
|
|
|
|
const STATES = ['success', 'warning', 'info']
|
|
|
|
function fail(message) {
|
|
process.stderr.write(`${message}\n`)
|
|
process.exit(1)
|
|
}
|
|
|
|
let input
|
|
|
|
try {
|
|
input = JSON.parse(process.argv[2] ?? '')
|
|
} catch {
|
|
fail('Expected one JSON argument: {"seed": "#rrggbb", "variant": "tonal-spot", ...}')
|
|
}
|
|
|
|
const hex = /^#[0-9a-f]{6}$/i
|
|
const Scheme = VARIANTS[input.variant]
|
|
const SPECS = ['2021', '2025']
|
|
const spec = input.spec ?? '2025'
|
|
|
|
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
|
|
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
|
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
|
|
|
|
for (const state of STATES) {
|
|
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
|
}
|
|
|
|
const contrast = Number(input.contrast ?? 0)
|
|
const harmonize = Boolean(input.harmonize ?? false)
|
|
|
|
if (!(contrast >= -1 && contrast < LEVELS.medium)) {
|
|
fail(`The contrast level ${input.contrast} is the standard block's, from -1 to below ${LEVELS.medium}. Medium (${LEVELS.medium}) and high (${LEVELS.high}) are always generated beside it, under [data-contrast]; the head script picks one.`)
|
|
}
|
|
|
|
const source = Hct.fromInt(argbFromHex(input.seed))
|
|
const colors = new MaterialDynamicColors()
|
|
|
|
/**
|
|
* The contrast curves of Google's 2025 spec, from its private getCurve()
|
|
* (dynamiccolor/color_spec_2025.js).
|
|
*/
|
|
function curve(defaultContrast) {
|
|
return {
|
|
1.5: new ContrastCurve(1.5, 1.5, 3, 5.5),
|
|
3: new ContrastCurve(3, 3, 4.5, 7),
|
|
4.5: new ContrastCurve(4.5, 4.5, 7, 11),
|
|
6: new ContrastCurve(6, 6, 7, 11),
|
|
7: new ContrastCurve(7, 7, 11, 21),
|
|
}[defaultContrast]
|
|
}
|
|
|
|
/** The tone that holds the palette's chroma, searching up or down — Google's findBestToneForChroma. */
|
|
function bestToneForChroma(hue, chroma, tone, byDecreasingTone) {
|
|
let answer = tone
|
|
let best = Hct.from(hue, chroma, answer)
|
|
|
|
while (best.chroma < chroma) {
|
|
if (tone < 0 || tone > 100) break
|
|
|
|
tone += byDecreasingTone ? -1 : 1
|
|
|
|
const candidate = Hct.from(hue, chroma, tone)
|
|
|
|
if (best.chroma < candidate.chroma) {
|
|
best = candidate
|
|
answer = tone
|
|
}
|
|
}
|
|
|
|
return answer
|
|
}
|
|
|
|
/** Google's tMaxC: the most chromatic tone, from the top. */
|
|
function tMaxC(palette, lowerBound = 0, upperBound = 100) {
|
|
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 100, true))
|
|
}
|
|
|
|
/** Google's tMinC: the most chromatic tone, from the bottom. */
|
|
function tMinC(palette, lowerBound = 0, upperBound = 100) {
|
|
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 0, false))
|
|
}
|
|
|
|
/**
|
|
* success, warning and info as M3 custom colours: the four roles of a semantic colour, built on
|
|
* their own tonal palette exactly as Google builds error / on-error / error-container /
|
|
* on-error-container — the same tones, contrast curves and tone delta pairs in both specs
|
|
* (dynamiccolor/color_spec_2021.js and color_spec_2025.js, "Errors [E]"). Because they are
|
|
* DynamicColors against the page's own DynamicScheme, they follow its contrast level, its dark
|
|
* tones, its spec version and its platform the way every other role does; the 2021 recipe they
|
|
* used before (customColor) was fixed at tones 40/100/90/10 whatever the scheme asked for.
|
|
*/
|
|
function stateRoles(name, palette) {
|
|
const of = () => palette
|
|
let color, onColor, container, onContainer
|
|
|
|
color = extendSpecVersion(
|
|
DynamicColor.fromPalette({
|
|
name,
|
|
palette: of,
|
|
tone: (s) => (s.isDark ? 80 : 40),
|
|
isBackground: true,
|
|
background: (s) => colors.highestSurface(s),
|
|
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 7),
|
|
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
|
|
}),
|
|
'2025',
|
|
DynamicColor.fromPalette({
|
|
name,
|
|
palette: of,
|
|
tone: (s) => (s.platform === 'phone' ? (s.isDark ? tMinC(palette, 0, 98) : tMaxC(palette)) : tMinC(palette)),
|
|
isBackground: true,
|
|
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : colors.surfaceContainerHigh()),
|
|
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
|
|
toneDeltaPair: (s) => (s.platform === 'phone' ? new ToneDeltaPair(container, color, 5, 'relative_lighter', true, 'farther') : undefined),
|
|
}),
|
|
)
|
|
|
|
onColor = extendSpecVersion(
|
|
DynamicColor.fromPalette({
|
|
name: `on_${name}`,
|
|
palette: of,
|
|
tone: (s) => (s.isDark ? 20 : 100),
|
|
background: () => color,
|
|
contrastCurve: () => new ContrastCurve(4.5, 7, 11, 21),
|
|
}),
|
|
'2025',
|
|
DynamicColor.fromPalette({
|
|
name: `on_${name}`,
|
|
palette: of,
|
|
// Google's on-error sits on error-dim off the phone; a state colour has no dim role.
|
|
background: () => color,
|
|
contrastCurve: (s) => (s.platform === 'phone' ? curve(6) : curve(7)),
|
|
}),
|
|
)
|
|
|
|
container = extendSpecVersion(
|
|
DynamicColor.fromPalette({
|
|
name: `${name}_container`,
|
|
palette: of,
|
|
tone: (s) => (s.isDark ? 30 : 90),
|
|
isBackground: true,
|
|
background: (s) => colors.highestSurface(s),
|
|
contrastCurve: () => new ContrastCurve(1, 1, 3, 4.5),
|
|
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
|
|
}),
|
|
'2025',
|
|
DynamicColor.fromPalette({
|
|
name: `${name}_container`,
|
|
palette: of,
|
|
tone: (s) => (s.platform === 'watch' ? 30 : s.isDark ? tMinC(palette, 30, 93) : tMaxC(palette, 0, 90)),
|
|
isBackground: true,
|
|
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : undefined),
|
|
toneDeltaPair: (s) => (s.platform === 'watch' ? new ToneDeltaPair(container, color, 10, 'darker', true, 'farther') : undefined),
|
|
contrastCurve: (s) => (s.platform === 'phone' && s.contrastLevel > 0 ? curve(1.5) : undefined),
|
|
}),
|
|
)
|
|
|
|
onContainer = extendSpecVersion(
|
|
DynamicColor.fromPalette({
|
|
name: `on_${name}_container`,
|
|
palette: of,
|
|
tone: (s) => (s.variant === Variant.MONOCHROME ? (s.isDark ? 90 : 10) : s.isDark ? 90 : 30),
|
|
background: () => container,
|
|
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 11),
|
|
}),
|
|
'2025',
|
|
DynamicColor.fromPalette({
|
|
name: `on_${name}_container`,
|
|
palette: of,
|
|
background: () => container,
|
|
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
|
|
}),
|
|
)
|
|
|
|
return {
|
|
[name]: color,
|
|
[`on-${name}`]: onColor,
|
|
[`${name}-container`]: container,
|
|
[`on-${name}-container`]: onContainer,
|
|
}
|
|
}
|
|
|
|
// Harmonisation pulls a state's hue towards the seed, so the three read as part of the scheme
|
|
// (styles/color/advanced/define-new-colors). Off by default: a state has to stay recognisable
|
|
// whatever the brand is, and a green that has turned blue no longer says "success".
|
|
const states = Object.assign({}, ...STATES.map((state) => {
|
|
const value = argbFromHex(input[state])
|
|
|
|
return stateRoles(state, TonalPalette.fromInt(harmonize ? Blend.harmonize(value, argbFromHex(input.seed)) : value))
|
|
}))
|
|
|
|
function roles(isDark, contrastLevel) {
|
|
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
|
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
|
|
// M3's original colour, for an application whose palette was generated with it.
|
|
const scheme = new Scheme(source, isDark, contrastLevel, spec)
|
|
const out = {}
|
|
|
|
for (const color of colors.allColors) {
|
|
if (color && !NOT_ROLES.test(color.name)) {
|
|
out[color.name.replaceAll('_', '-')] = hexFromArgb(color.getArgb(scheme))
|
|
}
|
|
}
|
|
|
|
for (const [role, color] of Object.entries(states)) {
|
|
out[role] = hexFromArgb(color.getArgb(scheme))
|
|
}
|
|
|
|
return { scheme, out }
|
|
}
|
|
|
|
/** One contrast level: both themes, with each theme's inverse state colours from the other. */
|
|
function level(contrastLevel) {
|
|
const light = roles(false, contrastLevel)
|
|
const dark = roles(true, contrastLevel)
|
|
|
|
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
|
|
for (const state of ['error', ...STATES]) {
|
|
light.out[`inverse-${state}`] = dark.out[state]
|
|
dark.out[`inverse-${state}`] = light.out[state]
|
|
}
|
|
|
|
return { spec: light.scheme.specVersion, light: light.out, dark: dark.out }
|
|
}
|
|
|
|
const standard = level(contrast)
|
|
const medium = level(LEVELS.medium)
|
|
const high = level(LEVELS.high)
|
|
|
|
process.stdout.write(JSON.stringify({
|
|
seed: input.seed.toLowerCase(),
|
|
variant: input.variant,
|
|
spec: standard.spec,
|
|
harmonize,
|
|
contrast: {
|
|
standard: contrast,
|
|
medium: { light: medium.light, dark: medium.dark },
|
|
high: { light: high.light, dark: high.dark },
|
|
},
|
|
light: standard.light,
|
|
dark: standard.dark,
|
|
}))
|