The three state colours are DynamicColors on their own tonal palette now, built exactly as Google builds error/on-error/error-container/on-error-container in both specs, so the scheme's contrast level, dark tones, spec version and platform reach them as they reach every other role; --harmonize (and a profile's harmonize) pulls each source towards the seed, off by default. material:scheme also writes M3's medium (0.5) and high (1.0) levels for both themes and every profile, keyed on data-contrast and never on a media query, and --contrast now moves the standard block alone. Scheme::load() takes the level; the mail theme stays on standard. Plan: docs/plans/material-3-alignment.md, steps 6 and 7 (core C1, C2, C15). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
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 standard contrast level runs from -1 to below ${LEVELS.medium}, "${input.contrast}" given; medium and high are generated as their own blocks.`)
|
|
}
|
|
|
|
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,
|
|
}))
|