/** * 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, }))