tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
Colour, shape, type, elevation and motion as tokens and Tailwind utilities; `php artisan material:scheme`, which generates an app's colour roles with Google's material-color-utilities (spec 2025); the theme head script with light, dark and system and its Alpine store; Google Sans Flex; every Material Symbol (4,135, outlined and filled) drawn by <x-icon> without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as <x-shape>; the x-figure directive; the Toasts concern; DesignGuard for applications' tests; and a showcase with every token, both themes side by side and an icon search. Colour utilities are `@theme inline`, so a section with its own data-theme repaints; without it they resolve once on :root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
119 lines
4.0 KiB
JavaScript
119 lines
4.0 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.)
|
|
*
|
|
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
|
|
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
|
*/
|
|
import {
|
|
argbFromHex,
|
|
customColor,
|
|
hexFromArgb,
|
|
Hct,
|
|
MaterialDynamicColors,
|
|
SchemeContent,
|
|
SchemeExpressive,
|
|
SchemeFidelity,
|
|
SchemeFruitSalad,
|
|
SchemeMonochrome,
|
|
SchemeNeutral,
|
|
SchemeRainbow,
|
|
SchemeTonalSpot,
|
|
SchemeVibrant,
|
|
} from '@material/material-color-utilities'
|
|
|
|
const VARIANTS = {
|
|
'tonal-spot': SchemeTonalSpot,
|
|
vibrant: SchemeVibrant,
|
|
expressive: SchemeExpressive,
|
|
neutral: SchemeNeutral,
|
|
fidelity: SchemeFidelity,
|
|
content: SchemeContent,
|
|
monochrome: SchemeMonochrome,
|
|
rainbow: SchemeRainbow,
|
|
'fruit-salad': SchemeFruitSalad,
|
|
}
|
|
|
|
// Key colours seed the palettes; they are not roles a stylesheet paints with.
|
|
const NOT_ROLES = /_palette_key_color$/
|
|
|
|
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]
|
|
|
|
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(', ')}.`)
|
|
|
|
for (const state of ['success', 'warning', 'info']) {
|
|
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
|
}
|
|
|
|
const contrast = Number(input.contrast ?? 0)
|
|
|
|
if (!(contrast >= -1 && contrast <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
|
|
|
|
const source = Hct.fromInt(argbFromHex(input.seed))
|
|
const colors = new MaterialDynamicColors()
|
|
|
|
function roles(isDark) {
|
|
// 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, …).
|
|
const scheme = new Scheme(source, isDark, contrast, '2025')
|
|
const out = {}
|
|
|
|
for (const color of colors.allColors) {
|
|
if (color && !NOT_ROLES.test(color.name)) {
|
|
out[color.name.replaceAll('_', '-')] = hexFromArgb(color.getArgb(scheme))
|
|
}
|
|
}
|
|
|
|
return { scheme, out }
|
|
}
|
|
|
|
const light = roles(false)
|
|
const dark = roles(true)
|
|
|
|
// success, warning and info are M3 custom colours, harmonisation off: harmonising pulls
|
|
// each hue towards the seed, and a state has to stay recognisable whatever the brand is.
|
|
for (const state of ['success', 'warning', 'info']) {
|
|
const group = customColor(argbFromHex(input.seed), { name: state, value: argbFromHex(input[state]), blend: false })
|
|
|
|
for (const [theme, colours] of [['light', light.out], ['dark', dark.out]]) {
|
|
colours[state] = hexFromArgb(group[theme].color)
|
|
colours[`on-${state}`] = hexFromArgb(group[theme].onColor)
|
|
colours[`${state}-container`] = hexFromArgb(group[theme].colorContainer)
|
|
colours[`on-${state}-container`] = hexFromArgb(group[theme].onColorContainer)
|
|
}
|
|
}
|
|
|
|
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
|
|
for (const state of ['error', 'success', 'warning', 'info']) {
|
|
light.out[`inverse-${state}`] = dark.out[state]
|
|
dark.out[`inverse-${state}`] = light.out[state]
|
|
}
|
|
|
|
process.stdout.write(JSON.stringify({
|
|
seed: input.seed.toLowerCase(),
|
|
variant: input.variant,
|
|
spec: light.scheme.specVersion,
|
|
contrast,
|
|
light: light.out,
|
|
dark: dark.out,
|
|
}))
|