Give success, warning and info the 2025 spec and three contrast levels
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
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b1fc0c9cfa
commit
4263982369
+211
-30
@@ -4,15 +4,23 @@
|
||||
* 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.)
|
||||
* 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, 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}}.
|
||||
* 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,
|
||||
customColor,
|
||||
Blend,
|
||||
clampDouble,
|
||||
DynamicColor,
|
||||
extendSpecVersion,
|
||||
hexFromArgb,
|
||||
Hct,
|
||||
MaterialDynamicColors,
|
||||
@@ -25,7 +33,12 @@ import {
|
||||
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,
|
||||
@@ -39,9 +52,15 @@ const VARIANTS = {
|
||||
'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)
|
||||
@@ -64,22 +83,179 @@ if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${inp
|
||||
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']) {
|
||||
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 <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
|
||||
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()
|
||||
|
||||
function roles(isDark) {
|
||||
/**
|
||||
* 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, contrast, spec)
|
||||
const scheme = new Scheme(source, isDark, contrastLevel, spec)
|
||||
const out = {}
|
||||
|
||||
for (const color of colors.allColors) {
|
||||
@@ -88,36 +264,41 @@ function roles(isDark) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [role, color] of Object.entries(states)) {
|
||||
out[role] = hexFromArgb(color.getArgb(scheme))
|
||||
}
|
||||
|
||||
return { scheme, out }
|
||||
}
|
||||
|
||||
const light = roles(false)
|
||||
const dark = roles(true)
|
||||
/** 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)
|
||||
|
||||
// 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', ...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 }
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
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: light.scheme.specVersion,
|
||||
contrast,
|
||||
light: light.out,
|
||||
dark: dark.out,
|
||||
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,
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user