Merge branch 'worktree-agent-a9537eabbf38e0608'

# Conflicts:
#	resources/views/components/scheme-picker.blade.php
#	resources/views/components/theme-script.blade.php
#	resources/views/showcase/sections/colour.blade.php
#	tests/Feature/Components/ThemeScriptTest.php
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:43:37 +02:00
23 changed files with 4086 additions and 408 deletions
+211 -30
View File
@@ -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,
}))