Files
livewire-material/bin/scheme.mjs
T
Andreas Reinhold / reiniandClaude Opus 5 3a7aa96ec7 Add a colour spec choice to material:scheme
The scheme script always generated with material-color-utilities' 2025 spec.
An application whose palette was generated with M3's original 2021 colour
(ReStride) could not reproduce it. The script now takes `spec` ('2025' by
default, or '2021') and refuses anything else, and the command takes
`--spec`, validated before Node runs.

Colour profiles may set their own `spec`, `success`, `warning` and `info`;
without them the command's options apply. The stylesheet header writes out
every option that differs from its default, so the recorded command
regenerates the file, and a profile whose spec differs from the header's
names it. Default output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:52:50 +02:00

124 lines
4.3 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, spec, contrast, success, warning, info}. `spec` is
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
* 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]
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 ['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, …). 2021 is
// M3's original colour, for an application whose palette was generated with it.
const scheme = new Scheme(source, isDark, contrast, spec)
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,
}))