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:
Andreas Reinhold / reini
2026-09-14 05:30:48 +02:00
co-authored by Claude Fable 5.1
parent b1fc0c9cfa
commit 4263982369
11 changed files with 3697 additions and 372 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,
}))
+320 -29
View File
@@ -6,6 +6,11 @@
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme before the
* first paint; the light block also stands without it.
*
* M3's medium and high contrast levels follow the standard blocks, keyed on
* data-contrast — which the head script writes from the visitor's choice, or from the
* operating system's contrast setting while that choice is `system`. No media query
* decides here, as none decides the theme.
*/
:root,
@@ -61,22 +66,22 @@
--md-sys-color-on-error: #fff7f7;
--md-sys-color-error-container: #f97386;
--md-sys-color-on-error-container: #6e0523;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success: #006d46;
--md-sys-color-on-success: #e7ffed;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-on-success-container: #00734a;
--md-sys-color-warning: #7c5900;
--md-sys-color-on-warning: #fff8f1;
--md-sys-color-warning-container: #fab925;
--md-sys-color-on-warning-container: #6a4b00;
--md-sys-color-info: #005bc5;
--md-sys-color-on-info: #f9f8ff;
--md-sys-color-info-container: #1c7afc;
--md-sys-color-on-info-container: #001435;
--md-sys-color-inverse-error: #f97386;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
--md-sys-color-inverse-success: #3bb27b;
--md-sys-color-inverse-warning: #f3b31e;
--md-sys-color-inverse-info: #699cff;
}
[data-theme='dark'] {
@@ -131,20 +136,306 @@
--md-sys-color-on-error: #490013;
--md-sys-color-error-container: #871c34;
--md-sys-color-on-error-container: #ff97a3;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-success: #3bb27b;
--md-sys-color-on-success: #002615;
--md-sys-color-success-container: #059460;
--md-sys-color-on-success-container: #001e10;
--md-sys-color-warning: #f3b31e;
--md-sys-color-on-warning: #4e3600;
--md-sys-color-warning-container: #e4a604;
--md-sys-color-on-warning-container: #593f00;
--md-sys-color-info: #699cff;
--md-sys-color-on-info: #001e4b;
--md-sys-color-info-container: #0c74f6;
--md-sys-color-on-info-container: #000a23;
--md-sys-color-inverse-error: #a8364b;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
--md-sys-color-inverse-success: #006d46;
--md-sys-color-inverse-warning: #7c5900;
--md-sys-color-inverse-info: #005bc5;
}
[data-contrast='medium'],
[data-contrast='medium'][data-theme='light'],
[data-contrast='medium'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fdf7fe;
--md-sys-color-on-background: #25232b;
--md-sys-color-surface: #fdf7fe;
--md-sys-color-surface-dim: #ded8e4;
--md-sys-color-surface-bright: #fdf7fe;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f8f1fa;
--md-sys-color-surface-container: #f2ecf5;
--md-sys-color-surface-container-high: #ece6f0;
--md-sys-color-surface-container-highest: #e7e0ec;
--md-sys-color-on-surface: #25232b;
--md-sys-color-on-surface-variant: #45414b;
--md-sys-color-outline: #615d68;
--md-sys-color-outline-variant: #7d7983;
--md-sys-color-inverse-surface: #0f0d12;
--md-sys-color-inverse-on-surface: #c8c3c9;
--md-sys-color-primary: #483b6b;
--md-sys-color-primary-dim: #3c305f;
--md-sys-color-on-primary: #e1d3ff;
--md-sys-color-primary-container: #7b6da0;
--md-sys-color-on-primary-container: #ffffff;
--md-sys-color-primary-fixed: #7b6da0;
--md-sys-color-primary-fixed-dim: #6e6093;
--md-sys-color-on-primary-fixed: #ffffff;
--md-sys-color-on-primary-fixed-variant: #ffffff;
--md-sys-color-inverse-primary: #d4c3fd;
--md-sys-color-secondary: #464054;
--md-sys-color-secondary-dim: #3a3548;
--md-sys-color-on-secondary: #dfd5ef;
--md-sys-color-secondary-container: #787187;
--md-sys-color-on-secondary-container: #ffffff;
--md-sys-color-secondary-fixed: #787187;
--md-sys-color-secondary-fixed-dim: #6c657b;
--md-sys-color-on-secondary-fixed: #ffffff;
--md-sys-color-on-secondary-fixed-variant: #ffffff;
--md-sys-color-tertiary: #5c3653;
--md-sys-color-tertiary-dim: #502b47;
--md-sys-color-on-tertiary: #ffcaee;
--md-sys-color-tertiary-container: #936787;
--md-sys-color-on-tertiary-container: #ffffff;
--md-sys-color-tertiary-fixed: #936787;
--md-sys-color-tertiary-fixed-dim: #855b7a;
--md-sys-color-on-tertiary-fixed: #ffffff;
--md-sys-color-on-tertiary-fixed-variant: #ffffff;
--md-sys-color-error: #821830;
--md-sys-color-error-dim: #6b0221;
--md-sys-color-on-error: #ffcdd1;
--md-sys-color-error-container: #c44b5f;
--md-sys-color-on-error-container: #ffffff;
--md-sys-color-success: #004d30;
--md-sys-color-on-success: #7df0b3;
--md-sys-color-success-container: #008656;
--md-sys-color-on-success-container: #ffffff;
--md-sys-color-warning: #593e00;
--md-sys-color-on-warning: #ffd385;
--md-sys-color-warning-container: #986d00;
--md-sys-color-on-warning-container: #ffffff;
--md-sys-color-info: #003f8e;
--md-sys-color-on-info: #cbd9ff;
--md-sys-color-info-container: #006fef;
--md-sys-color-on-info-container: #ffffff;
--md-sys-color-inverse-error: #ff9da8;
--md-sys-color-inverse-success: #5ace94;
--md-sys-color-inverse-warning: #f3b31e;
--md-sys-color-inverse-info: #98b8ff;
}
[data-contrast='medium'][data-theme='dark'],
[data-contrast='medium'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #0f0d12;
--md-sys-color-on-background: #ffffff;
--md-sys-color-surface: #0f0d12;
--md-sys-color-surface-dim: #0f0d12;
--md-sys-color-surface-bright: #2e2b34;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #141218;
--md-sys-color-surface-container: #1b181f;
--md-sys-color-surface-container-high: #211e26;
--md-sys-color-surface-container-highest: #27242d;
--md-sys-color-on-surface: #ffffff;
--md-sys-color-on-surface-variant: #bcb6c2;
--md-sys-color-outline: #96919c;
--md-sys-color-outline-variant: #78737e;
--md-sys-color-inverse-surface: #fdf7fe;
--md-sys-color-inverse-on-surface: #39373c;
--md-sys-color-primary: #cdc0ec;
--md-sys-color-primary-dim: #bfb2de;
--md-sys-color-on-primary: #3a3054;
--md-sys-color-primary-container: #7a6f96;
--md-sys-color-on-primary-container: #ffffff;
--md-sys-color-primary-fixed: #ded0fe;
--md-sys-color-primary-fixed-dim: #d0c3ef;
--md-sys-color-on-primary-fixed: #180e30;
--md-sys-color-on-primary-fixed-variant: #3c3256;
--md-sys-color-inverse-primary: #5a4f75;
--md-sys-color-secondary: #cbc2db;
--md-sys-color-secondary-dim: #beb5cd;
--md-sys-color-on-secondary: #393347;
--md-sys-color-secondary-container: #787187;
--md-sys-color-on-secondary-container: #ffffff;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #221d2f;
--md-sys-color-on-secondary-fixed-variant: #423c50;
--md-sys-color-tertiary: #ffcfef;
--md-sys-color-tertiary-dim: #f4bfe3;
--md-sys-color-on-tertiary: #5e3855;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #542f4c;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #180015;
--md-sys-color-on-tertiary-fixed-variant: #4a2642;
--md-sys-color-error: #ff9da8;
--md-sys-color-error-dim: #ff8695;
--md-sys-color-on-error: #5f001c;
--md-sys-color-error-container: #c44b5f;
--md-sys-color-on-error-container: #ffffff;
--md-sys-color-success: #5ace94;
--md-sys-color-on-success: #00341e;
--md-sys-color-success-container: #008656;
--md-sys-color-on-success-container: #ffffff;
--md-sys-color-warning: #f3b31e;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #e4a604;
--md-sys-color-on-warning-container: #332200;
--md-sys-color-info: #98b8ff;
--md-sys-color-on-info: #002a62;
--md-sys-color-info-container: #006fef;
--md-sys-color-on-info-container: #ffffff;
--md-sys-color-inverse-error: #821830;
--md-sys-color-inverse-success: #004d30;
--md-sys-color-inverse-warning: #593e00;
--md-sys-color-inverse-info: #003f8e;
}
[data-contrast='high'],
[data-contrast='high'][data-theme='light'],
[data-contrast='high'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fdf7fe;
--md-sys-color-on-background: #000000;
--md-sys-color-surface: #fdf7fe;
--md-sys-color-surface-dim: #ded8e4;
--md-sys-color-surface-bright: #fdf7fe;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f8f1fa;
--md-sys-color-surface-container: #f2ecf5;
--md-sys-color-surface-container-high: #ece6f0;
--md-sys-color-surface-container-highest: #e7e0ec;
--md-sys-color-on-surface: #000000;
--md-sys-color-on-surface-variant: #25232b;
--md-sys-color-outline: #45414b;
--md-sys-color-outline-variant: #54505a;
--md-sys-color-inverse-surface: #0f0d12;
--md-sys-color-inverse-on-surface: #ffffff;
--md-sys-color-primary: #281b49;
--md-sys-color-primary-dim: #1e103f;
--md-sys-color-on-primary: #e1d3ff;
--md-sys-color-primary-container: #584a7b;
--md-sys-color-on-primary-container: #ffffff;
--md-sys-color-primary-fixed: #584a7b;
--md-sys-color-primary-fixed-dim: #4c3f6f;
--md-sys-color-on-primary-fixed: #ffffff;
--md-sys-color-on-primary-fixed-variant: #ffffff;
--md-sys-color-inverse-primary: #d4c3fd;
--md-sys-color-secondary: #262134;
--md-sys-color-secondary-dim: #1c1729;
--md-sys-color-on-secondary: #dfd6ef;
--md-sys-color-secondary-container: #554f64;
--md-sys-color-on-secondary-container: #ffffff;
--md-sys-color-secondary-fixed: #554f64;
--md-sys-color-secondary-fixed-dim: #494358;
--md-sys-color-on-secondary-fixed: #ffffff;
--md-sys-color-on-secondary-fixed-variant: #ffffff;
--md-sys-color-tertiary: #391733;
--md-sys-color-tertiary-dim: #2e0d28;
--md-sys-color-on-tertiary: #ffcbee;
--md-sys-color-tertiary-container: #6d4563;
--md-sys-color-on-tertiary-container: #ffffff;
--md-sys-color-tertiary-fixed: #6d4563;
--md-sys-color-tertiary-fixed-dim: #603a57;
--md-sys-color-on-tertiary-fixed: #ffffff;
--md-sys-color-on-tertiary-fixed-variant: #ffffff;
--md-sys-color-error: #500016;
--md-sys-color-error-dim: #3d000f;
--md-sys-color-on-error: #ffced2;
--md-sys-color-error-container: #97283e;
--md-sys-color-on-error-container: #ffffff;
--md-sys-color-success: #002a18;
--md-sys-color-on-success: #7df0b4;
--md-sys-color-success-container: #005f3c;
--md-sys-color-on-success-container: #ffffff;
--md-sys-color-warning: #312100;
--md-sys-color-on-warning: #ffd486;
--md-sys-color-warning-container: #6c4d00;
--md-sys-color-on-warning-container: #ffffff;
--md-sys-color-info: #002252;
--md-sys-color-on-info: #ccdaff;
--md-sys-color-info-container: #004eac;
--md-sys-color-on-info-container: #ffffff;
--md-sys-color-inverse-error: #ffdddf;
--md-sys-color-inverse-success: #89fcbf;
--md-sys-color-inverse-warning: #ffe2b1;
--md-sys-color-inverse-info: #dde5ff;
}
[data-contrast='high'][data-theme='dark'],
[data-contrast='high'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #0f0d12;
--md-sys-color-on-background: #ffffff;
--md-sys-color-surface: #0f0d12;
--md-sys-color-surface-dim: #0f0d12;
--md-sys-color-surface-bright: #2e2b34;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #141218;
--md-sys-color-surface-container: #1b181f;
--md-sys-color-surface-container-high: #211e26;
--md-sys-color-surface-container-highest: #27242d;
--md-sys-color-on-surface: #ffffff;
--md-sys-color-on-surface-variant: #eae3ef;
--md-sys-color-outline: #bcb6c2;
--md-sys-color-outline-variant: #a7a1ad;
--md-sys-color-inverse-surface: #fdf7fe;
--md-sys-color-inverse-on-surface: #000000;
--md-sys-color-primary: #ebe1ff;
--md-sys-color-primary-dim: #ded0fe;
--md-sys-color-on-primary: #302649;
--md-sys-color-primary-container: #aa9dc8;
--md-sys-color-on-primary-container: #000000;
--md-sys-color-primary-fixed: #ded0fe;
--md-sys-color-primary-fixed-dim: #d0c3ef;
--md-sys-color-on-primary-fixed: #000000;
--md-sys-color-on-primary-fixed-variant: #180e30;
--md-sys-color-inverse-primary: #3c3256;
--md-sys-color-secondary: #ebe1fb;
--md-sys-color-secondary-dim: #ddd3ed;
--md-sys-color-on-secondary: #2e293c;
--md-sys-color-secondary-container: #a8a0b8;
--md-sys-color-on-secondary-container: #000000;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #000000;
--md-sys-color-on-secondary-fixed-variant: #221d2f;
--md-sys-color-tertiary: #ffdbf2;
--md-sys-color-tertiary-dim: #fac5e9;
--md-sys-color-on-tertiary: #43203b;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #2e0e29;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #000000;
--md-sys-color-on-tertiary-fixed-variant: #180015;
--md-sys-color-error: #ffdddf;
--md-sys-color-error-dim: #ffc7cb;
--md-sys-color-on-error: #5f001c;
--md-sys-color-error-container: #ff798c;
--md-sys-color-on-error-container: #000000;
--md-sys-color-success: #89fcbf;
--md-sys-color-on-success: #00341e;
--md-sys-color-success-container: #42b880;
--md-sys-color-on-success-container: #000000;
--md-sys-color-warning: #ffe2b1;
--md-sys-color-on-warning: #3c2900;
--md-sys-color-warning-container: #e4a604;
--md-sys-color-on-warning-container: #000000;
--md-sys-color-info: #dde5ff;
--md-sys-color-on-info: #002a62;
--md-sys-color-info-container: #74a2ff;
--md-sys-color-on-info-container: #000000;
--md-sys-color-inverse-error: #500016;
--md-sys-color-inverse-success: #002a18;
--md-sys-color-inverse-warning: #312100;
--md-sys-color-inverse-info: #002252;
}
+305 -30
View File
@@ -2,7 +2,282 @@
"seed": "#6750a4",
"variant": "tonal-spot",
"spec": "2025",
"contrast": 0,
"harmonize": false,
"contrast": {
"standard": 0,
"medium": {
"light": {
"background": "#fdf7fe",
"on-background": "#25232b",
"surface": "#fdf7fe",
"surface-dim": "#ded8e4",
"surface-bright": "#fdf7fe",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f8f1fa",
"surface-container": "#f2ecf5",
"surface-container-high": "#ece6f0",
"surface-container-highest": "#e7e0ec",
"on-surface": "#25232b",
"on-surface-variant": "#45414b",
"outline": "#615d68",
"outline-variant": "#7d7983",
"inverse-surface": "#0f0d12",
"inverse-on-surface": "#c8c3c9",
"primary": "#483b6b",
"primary-dim": "#3c305f",
"on-primary": "#e1d3ff",
"primary-container": "#7b6da0",
"on-primary-container": "#ffffff",
"primary-fixed": "#7b6da0",
"primary-fixed-dim": "#6e6093",
"on-primary-fixed": "#ffffff",
"on-primary-fixed-variant": "#ffffff",
"inverse-primary": "#d4c3fd",
"secondary": "#464054",
"secondary-dim": "#3a3548",
"on-secondary": "#dfd5ef",
"secondary-container": "#787187",
"on-secondary-container": "#ffffff",
"secondary-fixed": "#787187",
"secondary-fixed-dim": "#6c657b",
"on-secondary-fixed": "#ffffff",
"on-secondary-fixed-variant": "#ffffff",
"tertiary": "#5c3653",
"tertiary-dim": "#502b47",
"on-tertiary": "#ffcaee",
"tertiary-container": "#936787",
"on-tertiary-container": "#ffffff",
"tertiary-fixed": "#936787",
"tertiary-fixed-dim": "#855b7a",
"on-tertiary-fixed": "#ffffff",
"on-tertiary-fixed-variant": "#ffffff",
"error": "#821830",
"error-dim": "#6b0221",
"on-error": "#ffcdd1",
"error-container": "#c44b5f",
"on-error-container": "#ffffff",
"success": "#004d30",
"on-success": "#7df0b3",
"success-container": "#008656",
"on-success-container": "#ffffff",
"warning": "#593e00",
"on-warning": "#ffd385",
"warning-container": "#986d00",
"on-warning-container": "#ffffff",
"info": "#003f8e",
"on-info": "#cbd9ff",
"info-container": "#006fef",
"on-info-container": "#ffffff",
"inverse-error": "#ff9da8",
"inverse-success": "#5ace94",
"inverse-warning": "#f3b31e",
"inverse-info": "#98b8ff"
},
"dark": {
"background": "#0f0d12",
"on-background": "#ffffff",
"surface": "#0f0d12",
"surface-dim": "#0f0d12",
"surface-bright": "#2e2b34",
"surface-container-lowest": "#000000",
"surface-container-low": "#141218",
"surface-container": "#1b181f",
"surface-container-high": "#211e26",
"surface-container-highest": "#27242d",
"on-surface": "#ffffff",
"on-surface-variant": "#bcb6c2",
"outline": "#96919c",
"outline-variant": "#78737e",
"inverse-surface": "#fdf7fe",
"inverse-on-surface": "#39373c",
"primary": "#cdc0ec",
"primary-dim": "#bfb2de",
"on-primary": "#3a3054",
"primary-container": "#7a6f96",
"on-primary-container": "#ffffff",
"primary-fixed": "#ded0fe",
"primary-fixed-dim": "#d0c3ef",
"on-primary-fixed": "#180e30",
"on-primary-fixed-variant": "#3c3256",
"inverse-primary": "#5a4f75",
"secondary": "#cbc2db",
"secondary-dim": "#beb5cd",
"on-secondary": "#393347",
"secondary-container": "#787187",
"on-secondary-container": "#ffffff",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#221d2f",
"on-secondary-fixed-variant": "#423c50",
"tertiary": "#ffcfef",
"tertiary-dim": "#f4bfe3",
"on-tertiary": "#5e3855",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#542f4c",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#180015",
"on-tertiary-fixed-variant": "#4a2642",
"error": "#ff9da8",
"error-dim": "#ff8695",
"on-error": "#5f001c",
"error-container": "#c44b5f",
"on-error-container": "#ffffff",
"success": "#5ace94",
"on-success": "#00341e",
"success-container": "#008656",
"on-success-container": "#ffffff",
"warning": "#f3b31e",
"on-warning": "#412d00",
"warning-container": "#e4a604",
"on-warning-container": "#332200",
"info": "#98b8ff",
"on-info": "#002a62",
"info-container": "#006fef",
"on-info-container": "#ffffff",
"inverse-error": "#821830",
"inverse-success": "#004d30",
"inverse-warning": "#593e00",
"inverse-info": "#003f8e"
}
},
"high": {
"light": {
"background": "#fdf7fe",
"on-background": "#000000",
"surface": "#fdf7fe",
"surface-dim": "#ded8e4",
"surface-bright": "#fdf7fe",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f8f1fa",
"surface-container": "#f2ecf5",
"surface-container-high": "#ece6f0",
"surface-container-highest": "#e7e0ec",
"on-surface": "#000000",
"on-surface-variant": "#25232b",
"outline": "#45414b",
"outline-variant": "#54505a",
"inverse-surface": "#0f0d12",
"inverse-on-surface": "#ffffff",
"primary": "#281b49",
"primary-dim": "#1e103f",
"on-primary": "#e1d3ff",
"primary-container": "#584a7b",
"on-primary-container": "#ffffff",
"primary-fixed": "#584a7b",
"primary-fixed-dim": "#4c3f6f",
"on-primary-fixed": "#ffffff",
"on-primary-fixed-variant": "#ffffff",
"inverse-primary": "#d4c3fd",
"secondary": "#262134",
"secondary-dim": "#1c1729",
"on-secondary": "#dfd6ef",
"secondary-container": "#554f64",
"on-secondary-container": "#ffffff",
"secondary-fixed": "#554f64",
"secondary-fixed-dim": "#494358",
"on-secondary-fixed": "#ffffff",
"on-secondary-fixed-variant": "#ffffff",
"tertiary": "#391733",
"tertiary-dim": "#2e0d28",
"on-tertiary": "#ffcbee",
"tertiary-container": "#6d4563",
"on-tertiary-container": "#ffffff",
"tertiary-fixed": "#6d4563",
"tertiary-fixed-dim": "#603a57",
"on-tertiary-fixed": "#ffffff",
"on-tertiary-fixed-variant": "#ffffff",
"error": "#500016",
"error-dim": "#3d000f",
"on-error": "#ffced2",
"error-container": "#97283e",
"on-error-container": "#ffffff",
"success": "#002a18",
"on-success": "#7df0b4",
"success-container": "#005f3c",
"on-success-container": "#ffffff",
"warning": "#312100",
"on-warning": "#ffd486",
"warning-container": "#6c4d00",
"on-warning-container": "#ffffff",
"info": "#002252",
"on-info": "#ccdaff",
"info-container": "#004eac",
"on-info-container": "#ffffff",
"inverse-error": "#ffdddf",
"inverse-success": "#89fcbf",
"inverse-warning": "#ffe2b1",
"inverse-info": "#dde5ff"
},
"dark": {
"background": "#0f0d12",
"on-background": "#ffffff",
"surface": "#0f0d12",
"surface-dim": "#0f0d12",
"surface-bright": "#2e2b34",
"surface-container-lowest": "#000000",
"surface-container-low": "#141218",
"surface-container": "#1b181f",
"surface-container-high": "#211e26",
"surface-container-highest": "#27242d",
"on-surface": "#ffffff",
"on-surface-variant": "#eae3ef",
"outline": "#bcb6c2",
"outline-variant": "#a7a1ad",
"inverse-surface": "#fdf7fe",
"inverse-on-surface": "#000000",
"primary": "#ebe1ff",
"primary-dim": "#ded0fe",
"on-primary": "#302649",
"primary-container": "#aa9dc8",
"on-primary-container": "#000000",
"primary-fixed": "#ded0fe",
"primary-fixed-dim": "#d0c3ef",
"on-primary-fixed": "#000000",
"on-primary-fixed-variant": "#180e30",
"inverse-primary": "#3c3256",
"secondary": "#ebe1fb",
"secondary-dim": "#ddd3ed",
"on-secondary": "#2e293c",
"secondary-container": "#a8a0b8",
"on-secondary-container": "#000000",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#000000",
"on-secondary-fixed-variant": "#221d2f",
"tertiary": "#ffdbf2",
"tertiary-dim": "#fac5e9",
"on-tertiary": "#43203b",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#2e0e29",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#000000",
"on-tertiary-fixed-variant": "#180015",
"error": "#ffdddf",
"error-dim": "#ffc7cb",
"on-error": "#5f001c",
"error-container": "#ff798c",
"on-error-container": "#000000",
"success": "#89fcbf",
"on-success": "#00341e",
"success-container": "#42b880",
"on-success-container": "#000000",
"warning": "#ffe2b1",
"on-warning": "#3c2900",
"warning-container": "#e4a604",
"on-warning-container": "#000000",
"info": "#dde5ff",
"on-info": "#002a62",
"info-container": "#74a2ff",
"on-info-container": "#000000",
"inverse-error": "#500016",
"inverse-success": "#002a18",
"inverse-warning": "#312100",
"inverse-info": "#002252"
}
}
},
"light": {
"background": "#fdf7fe",
"on-background": "#34313a",
@@ -53,22 +328,22 @@
"on-error": "#fff7f7",
"error-container": "#f97386",
"on-error-container": "#6e0523",
"success": "#006c45",
"on-success": "#ffffff",
"success": "#006d46",
"on-success": "#e7ffed",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"on-success-container": "#00734a",
"warning": "#7c5900",
"on-warning": "#fff8f1",
"warning-container": "#fab925",
"on-warning-container": "#6a4b00",
"info": "#005bc5",
"on-info": "#f9f8ff",
"info-container": "#1c7afc",
"on-info-container": "#001435",
"inverse-error": "#f97386",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
"inverse-success": "#3bb27b",
"inverse-warning": "#f3b31e",
"inverse-info": "#699cff"
},
"dark": {
"background": "#0f0d12",
@@ -120,21 +395,21 @@
"on-error": "#490013",
"error-container": "#871c34",
"on-error-container": "#ff97a3",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"success": "#3bb27b",
"on-success": "#002615",
"success-container": "#059460",
"on-success-container": "#001e10",
"warning": "#f3b31e",
"on-warning": "#4e3600",
"warning-container": "#e4a604",
"on-warning-container": "#593f00",
"info": "#699cff",
"on-info": "#001e4b",
"info-container": "#0c74f6",
"on-info-container": "#000a23",
"inverse-error": "#a8364b",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
"inverse-success": "#006d46",
"inverse-warning": "#7c5900",
"inverse-info": "#005bc5"
}
}
File diff suppressed because one or more lines are too long
+112 -19
View File
@@ -19,7 +19,8 @@ class SchemeCommand extends Command
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
{--contrast=0 : The contrast level, from -1 to 1}
{--contrast=0 : The standard level\'s contrast, from -1 to below 0.5; medium and high are always generated}
{--harmonize : Pull the success, warning and info colours towards the seed}
{--success=#22a06b : The source of the success colour}
{--warning=#e2a400 : The source of the warning colour}
{--info=#1d7afc : The source of the info colour}
@@ -38,6 +39,16 @@ class SchemeCommand extends Command
*/
protected const SPECS = ['2021', '2025'];
/**
* M3's other two contrast levels, generated for every scheme beside the standard one and
* keyed on <html data-contrast> (styles/color/roles, "What's new May 2025"): medium is
* 3:1 on the roles that carry text, high 7:1. Their levels are Google's, not the
* installation's — `--contrast` moves the standard block alone.
*
* @var array<string, float>
*/
protected const LEVELS = ['medium' => 0.5, 'high' => 1.0];
public function handle(Filesystem $files): int
{
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
@@ -51,11 +62,16 @@ class SchemeCommand extends Command
}
if (filled($this->argument('seed'))) {
if ($this->refusesContrast((float) $this->option('contrast'))) {
return self::FAILURE;
}
$input = [
'seed' => (string) $this->argument('seed'),
'variant' => (string) $this->option('variant'),
'spec' => $spec,
'contrast' => (float) $this->option('contrast'),
'harmonize' => (bool) $this->option('harmonize'),
'success' => (string) $this->option('success'),
'warning' => (string) $this->option('warning'),
'info' => (string) $this->option('info'),
@@ -87,12 +103,17 @@ class SchemeCommand extends Command
return self::FAILURE;
}
if ($this->refusesContrast((float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ")) {
return self::FAILURE;
}
// A profile's own spec and state colours win; without them, the command's options apply.
$scheme = $this->generate([
'seed' => (string) ($profile['seed'] ?? ''),
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
'spec' => (string) ($profile['spec'] ?? $spec),
'contrast' => (float) ($profile['contrast'] ?? 0),
'harmonize' => (bool) ($profile['harmonize'] ?? $this->option('harmonize')),
'success' => (string) ($profile['success'] ?? $this->option('success')),
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
'info' => (string) ($profile['info'] ?? $this->option('info')),
@@ -115,11 +136,33 @@ class SchemeCommand extends Command
]);
}
/**
* Whether the given standard contrast level is one this command generates, having said why
* it is not. Medium and high are M3's own levels and always generated; `--contrast` moves
* the standard block, which has to stay below them.
*/
protected function refusesContrast(float $contrast, string $context = ''): bool
{
if ($contrast >= self::LEVELS['medium'] || $contrast < -1) {
$this->components->error($context.sprintf(
'The contrast level %s is the standard block\'s, from -1 to below %s. Medium (%s) and high (%s) are always generated beside it, under [data-contrast]; the head script picks one.',
$contrast,
self::LEVELS['medium'],
self::LEVELS['medium'],
self::LEVELS['high'],
));
return true;
}
return false;
}
/**
* One scheme from Google's colour utilities, or null once the reason has been shown.
*
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
* @return array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array{standard: float, medium: array{light: array<string, string>, dark: array<string, string>}, high: array{light: array<string, string>, dark: array<string, string>}}, light: array<string, string>, dark: array<string, string>}|null
*/
protected function generate(array $input, string $context = ''): ?array
{
@@ -162,8 +205,8 @@ class SchemeCommand extends Command
* The stylesheet, headed by the command that regenerates it: every option that differs from
* its default is written out.
*
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
* @param array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
*/
protected function stylesheet(array $scheme, array $input): string
{
@@ -173,15 +216,16 @@ class SchemeCommand extends Command
->implode('');
$command = sprintf(
'php artisan material:scheme "%s" --variant=%s%s%s%s',
'php artisan material:scheme "%s" --variant=%s%s%s%s%s',
$scheme['seed'],
$scheme['variant'],
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
$scheme['contrast']['standard'] != 0 ? ' --contrast='.$scheme['contrast']['standard'] : '',
$scheme['harmonize'] ? ' --harmonize' : '',
$states,
);
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
$blocks = $this->levels($scheme);
return <<<CSS
/*
@@ -192,6 +236,11 @@ class SchemeCommand extends Command
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme before the
* first paint; the light block also stands without it.
*
* M3's medium and high contrast levels follow the standard blocks, keyed on
* data-contrast — which the head script writes from the visitor's choice, or from the
* operating system's contrast setting while that choice is `system`. No media query
* decides here, as none decides the theme.
*/
{$blocks}
@@ -205,18 +254,19 @@ class SchemeCommand extends Command
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
* page's profile.
*
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
* @param array<string, array{label: string, seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>}> $profiles
*/
protected function profilesStylesheet(array $profiles, string $default): string
{
$list = collect($profiles)
->map(fn (array $profile, string $name): string => sprintf(
' * %-12s %s, %s%s%s',
' * %-12s %s, %s%s%s%s',
$name,
$profile['seed'],
$profile['variant'],
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
$profile['contrast']['standard'] != 0 ? ', contrast '.$profile['contrast']['standard'] : '',
$profile['harmonize'] ? ', harmonized' : '',
))
->implode("\n");
@@ -232,25 +282,68 @@ class SchemeCommand extends Command
{$list}
*
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
* before the first paint.
* M3's contrast guarantee only as generated. The head script sets data-theme, data-scheme
* and data-contrast before the first paint.
*/
CSS;
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
$css .= "\n".$this->levels($profiles[$default]);
foreach ($profiles as $name => $profile) {
$css .= "\n".$this->blocks(
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
$profile,
);
$css .= "\n".$this->levels($profile, "[data-scheme='{$name}']");
}
return $css;
}
/**
* One scheme, whole: the standard level first, then M3's medium and high under
* data-contrast. A level's blocks are the standard ones with one more attribute, so they
* outrank them wherever both match, and the light blocks of a level come before its dark
* ones as in every other pair here. The nested `[data-contrast='high'] [data-theme='light']`
* form keeps a light panel inside a high-contrast dark page on the level the page asked for
* — the plain `[data-theme='light']` block would otherwise take it back to standard.
*
* @param array{contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
*/
protected function levels(array $scheme, string $prefix = ''): string
{
[$light, $dark] = $this->selectors($prefix);
$css = $this->blocks($light, $dark, $scheme);
foreach (array_keys(self::LEVELS) as $level) {
[$light, $dark] = $this->selectors($prefix."[data-contrast='{$level}']");
/** @var array{light: array<string, string>, dark: array<string, string>} $roles */
$roles = $scheme['contrast'][$level];
$css .= "\n".$this->blocks($light, $dark, $roles);
}
return $css;
}
/**
* The light and the dark selectors of one block, under the given prefix. Without a prefix
* the plain blocks stand on `:root` and on the attribute alone; with one, the prefix is on
* <html> and the theme may be on <html> or on a panel inside it, so both forms are written.
*
* @return array{list<string>, list<string>}
*/
protected function selectors(string $prefix): array
{
if ($prefix === '') {
return [[':root', "[data-theme='light']"], ["[data-theme='dark']"]];
}
return [
[$prefix, "{$prefix}[data-theme='light']", "{$prefix} [data-theme='light']"],
["{$prefix}[data-theme='dark']", "{$prefix} [data-theme='dark']"],
];
}
/**
* A light and a dark block of roles under the given selectors.
*
+50 -21
View File
@@ -18,6 +18,11 @@ use Throwable;
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
* refused, so nothing but a colour ever reaches a stylesheet.
*
* The roles at the top level are the standard contrast level; M3's medium and high levels sit
* under `contrast` and are asked for by name (`load($path, $profile, 'high')`). A file written
* before the levels existed — or one whose level is missing a role — falls back to the standard
* roles, so a mail or an error page never draws a hole.
*
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
* default under `default`, and the default's roles at the top level as a single scheme does. Which
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
@@ -30,6 +35,14 @@ use Throwable;
*/
class Scheme
{
/**
* M3's contrast levels, as `material:scheme` writes them and as <html data-contrast> names
* them. `standard` is the scheme's own level and has no attribute.
*
* @var list<string>
*/
public const LEVELS = ['standard', 'medium', 'high'];
/**
* @var (Closure(): ?string)|null
*/
@@ -46,14 +59,15 @@ class Scheme
}
/**
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
* The roles to draw: the given profile's, the active profile's, or the single scheme's, at
* the given contrast level (`standard`, `medium` or `high`; anything else is standard).
*
* @return array{light: array<string, string>, dark: array<string, string>}
*/
public static function load(?string $path = null, ?string $profile = null): array
public static function load(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
{
$data = static::data($path);
$profiles = static::profilesFrom($data);
$profiles = static::profilesFrom($data, $contrast);
if ($profiles !== []) {
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
@@ -61,13 +75,7 @@ class Scheme
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
}
$default = static::defaultScheme();
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
return [
'light' => [...$default['light'], ...$scheme['light']],
'dark' => [...$default['dark'], ...$scheme['dark']],
];
return static::scheme($data, $contrast);
}
/**
@@ -75,9 +83,9 @@ class Scheme
*
* @return array<string, string>
*/
public static function light(?string $path = null, ?string $profile = null): array
public static function light(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
{
return static::load($path, $profile)['light'];
return static::load($path, $profile, $contrast)['light'];
}
/**
@@ -85,9 +93,9 @@ class Scheme
*
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
public static function profiles(?string $path = null): array
public static function profiles(?string $path = null, string $contrast = 'standard'): array
{
return static::profilesFrom(static::data($path));
return static::profilesFrom(static::data($path), $contrast);
}
/**
@@ -116,13 +124,12 @@ class Scheme
* @param array<mixed> $data
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
protected static function profilesFrom(array $data): array
protected static function profilesFrom(array $data, string $contrast = 'standard'): array
{
if (! is_array($data['profiles'] ?? null)) {
return [];
}
$default = static::defaultScheme();
$profiles = [];
foreach ($data['profiles'] as $name => $profile) {
@@ -132,14 +139,32 @@ class Scheme
$profiles[$name] = [
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
...static::scheme($profile, $contrast),
];
}
return $profiles;
}
/**
* One scheme's roles at one contrast level: the level's own, over the standard ones it does
* not name, over the package's default. A scheme file written before the levels existed has
* only the standard roles, and draws them at every level rather than nothing.
*
* @param array<mixed> $data
* @return array{light: array<string, string>, dark: array<string, string>}
*/
protected static function scheme(array $data, string $contrast): array
{
$default = static::defaultScheme($contrast);
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
return [
'light' => [...$default['light'], ...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
'dark' => [...$default['dark'], ...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
];
}
/**
* @param array<mixed> $data
* @param non-empty-array<string, mixed> $profiles
@@ -162,15 +187,19 @@ class Scheme
}
/**
* The package's own scheme, which fills any role a file lacks.
* The package's own scheme at one contrast level, which fills any role a file lacks.
*
* @return array{light: array<string, string>, dark: array<string, string>}
*/
protected static function defaultScheme(): array
protected static function defaultScheme(string $contrast = 'standard'): array
{
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
return [
'light' => [...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
'dark' => [...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
];
}
/**
+166 -1
View File
@@ -3,6 +3,37 @@
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* The hue of a #rrggbb colour, in degrees: what harmonisation moves.
*/
function schemeHue(string $hex): float
{
[$r, $g, $b] = array_map(fn (string $channel): float => hexdec($channel) / 255, str_split(ltrim($hex, '#'), 2));
$chroma = max($r, $g, $b) - min($r, $g, $b);
if ($chroma === 0.0) {
return 0.0;
}
$sector = match (max($r, $g, $b)) {
$r => fmod(($g - $b) / $chroma, 6),
$g => (($b - $r) / $chroma) + 2,
default => (($r - $g) / $chroma) + 4,
};
return fmod(($sector * 60) + 360, 360);
}
/**
* How far apart two colours are on the hue circle, the short way round.
*/
function schemeHueDistance(string $one, string $other): float
{
$apart = abs(schemeHue($one) - schemeHue($other));
return min($apart, 360 - $apart);
}
beforeEach(function () {
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
$this->data = Str::replaceLast('.css', '.json', $this->stylesheet);
@@ -23,6 +54,8 @@ it('writes the scheme as a stylesheet and as data', function () {
->seed->toBe('#4f46e5')
->variant->toBe('tonal-spot')
->spec->toBe('2025')
->harmonize->toBeFalse()
->and($scheme['contrast']['standard'])->toBe(0)
->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light']))
->and($scheme['light'])->toHaveKeys(['primary', 'on-primary', 'tertiary-container', 'surface-container-high', 'outline-variant', 'success', 'on-warning-container', 'info-container'])
->and($scheme['light']['surface'])->not->toBe($scheme['dark']['surface'])
@@ -41,6 +74,137 @@ it('writes the scheme as a stylesheet and as data', function () {
expect($stylesheet)->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot');
});
it('writes M3\'s three contrast levels for both themes, keyed on data-contrast', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
// The standard level keeps the 1.x shape at the top; the other two sit under `contrast`.
expect(array_keys($scheme['contrast']))->toBe(['standard', 'medium', 'high'])
->and(array_keys($scheme['contrast']['medium']['light']))->toEqual(array_keys($scheme['light']))
->and(array_keys($scheme['contrast']['high']['dark']))->toEqual(array_keys($scheme['dark']));
foreach (['medium', 'high'] as $level) {
foreach (['light' => "[data-contrast='{$level}'] [data-theme='light'] {", 'dark' => "[data-contrast='{$level}'] [data-theme='dark'] {"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};");
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
expect($block)->toContain("--md-sys-color-{$role}: {$hex};");
}
}
expect($stylesheet)
->toContain("[data-contrast='{$level}'],\n[data-contrast='{$level}'][data-theme='light'],")
->toContain("[data-contrast='{$level}'][data-theme='dark'],");
}
// Every level differs from the one below it, in both themes and for the state colours too.
foreach (['light', 'dark'] as $theme) {
foreach (['on-surface-variant', 'outline', 'success', 'info', 'success-container'] as $role) {
expect($scheme['contrast']['medium'][$theme][$role])
->not->toBe($scheme[$theme][$role], "medium {$theme} {$role}")
->not->toBe($scheme['contrast']['high'][$theme][$role], "high {$theme} {$role}");
}
// The state colours are dynamic colours now, so the level reaches them as it does a
// built-in role. (A container whose tone already clears the level's ratio stays put,
// exactly as error's does — hence the pairs rather than every one of the four.)
foreach (['success', 'warning', 'info'] as $state) {
foreach ([$state, "on-{$state}", "on-{$state}-container"] as $role) {
expect($scheme['contrast']['high'][$theme][$role])->not->toBe($scheme[$theme][$role], "high {$theme} {$role}");
}
}
}
expect($stylesheet)->not->toContain('prefers-contrast');
});
it('puts a level\'s nested light blocks after the dark ones of the level below', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$stylesheet = File::get($this->stylesheet);
$at = fn (string $selector): int => strpos($stylesheet, $selector);
// A light panel inside a dark high-contrast page: its rule carries one attribute more than
// the plain dark block and stands after it, so it wins on specificity and on order.
expect($at("[data-theme='dark'] {"))->toBeLessThan($at("[data-contrast='high'] [data-theme='light'] {"))
->and($at("[data-contrast='medium'] [data-theme='light'] {"))->toBeLessThan($at("[data-contrast='medium'][data-theme='dark'],"))
->and($at("[data-contrast='medium'][data-theme='dark'],"))->toBeLessThan($at("[data-contrast='high'],"));
});
it('generates the levels for every profile, under its own data-scheme', function () {
config(['livewire-material.profiles' => [
'indigo' => ['seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['seed' => '#00897b', 'variant' => 'vibrant'],
]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
foreach ($scheme['profiles'] as $name => $profile) {
foreach (['medium', 'high'] as $level) {
$selector = "[data-scheme='{$name}'][data-contrast='{$level}'] [data-theme='dark'] {";
expect(Str::of($stylesheet)->after($selector)->before('}')->toString())
->toContain("--md-sys-color-primary: {$profile['contrast'][$level]['dark']['primary']};")
->and($profile['contrast'][$level]['light']['success'])->not->toBe($profile['light']['success']);
}
}
// The default profile's plain blocks come first, its levels next, then every profile's.
expect(strpos($stylesheet, "[data-contrast='high'],"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
});
it('refuses a standard contrast level at or above the medium one', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.5', '--output' => $this->stylesheet])
->expectsOutputToContain('Medium (0.5) and high (1) are always generated')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'contrast' => 1]]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "indigo": The contrast level 1')
->assertFailed();
});
it('keeps a standard level below medium, and records it in the header', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.3', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
expect($scheme['contrast']['standard'])->toBe(0.3)
->and($scheme['light']['primary'])->not->toBe($scheme['contrast']['medium']['light']['primary'])
->and(File::get($this->stylesheet))->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot --contrast=0.3');
});
it('harmonises the state colours towards the seed only when asked', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])->assertSuccessful();
$plain = json_decode(File::get($this->data), true);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--harmonize' => true, '--output' => $this->stylesheet])->assertSuccessful();
$harmonized = json_decode(File::get($this->data), true);
expect($harmonized['harmonize'])->toBeTrue()
->and($plain['harmonize'])->toBeFalse()
->and(File::get($this->stylesheet))->toContain('--variant=tonal-spot --harmonize');
foreach (['success', 'warning', 'info'] as $state) {
expect(schemeHueDistance($harmonized['light'][$state], '#4f46e5'))
->toBeLessThan(schemeHueDistance($plain['light'][$state], '#4f46e5'), "{$state} moved towards the seed")
->and($harmonized['contrast']['high']['dark'][$state])->not->toBe($plain['contrast']['high']['dark'][$state]);
}
});
it('generates a different scheme per variant', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
->assertSuccessful();
@@ -177,7 +341,8 @@ it('takes a profile\'s own spec and state colours, and the command\'s for a prof
->and($profiles['expressive'])->spec->toBe('2025')
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
// The state colours follow the spec now, so the same source is not the same colour.
->and($profiles['expressive']['dark']['info'])->not->toBe('#80cfff')
->and(File::get($this->stylesheet))
->toContain('(spec 2025)')
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
+28
View File
@@ -19,6 +19,11 @@ function writeProfiles(string $path, array $primaries, string $default): void
'contrast' => 0,
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
'contrast' => [
'standard' => 0,
'medium' => ['light' => ['primary' => '#332f8a'], 'dark' => ['surface' => '#0a0a0a']],
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff', 'surface' => '#000000']],
],
])->all();
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
@@ -69,6 +74,29 @@ it('loads a profile by name, whatever is active', function () {
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
});
it('loads a contrast level, falling back to the standard roles it does not name', function () {
expect(Scheme::load($this->path, 'teal', 'high')['light']['primary'])->toBe('#000000')
->and(Scheme::load($this->path, 'teal', 'high')['dark']['surface'])->toBe('#000000')
// The level names no surface in light, so the standard one stands.
->and(Scheme::light($this->path, 'teal', 'high')['surface'])->toBe('#fafafa')
->and(Scheme::load($this->path, 'teal', 'medium')['light']['primary'])->toBe('#332f8a')
->and(Scheme::load($this->path, 'teal')['light']['primary'])->toBe('#00897b')
// A level nobody generated, and a name that is not one, are the standard scheme.
->and(Scheme::load($this->path, 'teal', 'extreme')['light']['primary'])->toBe('#00897b')
->and(Scheme::profiles($this->path, 'high')['rose']['light']['primary'])->toBe('#000000')
->and(Scheme::profiles($this->path)['rose']['light']['primary'])->toBe('#c2185b');
});
it('draws a high contrast level from the package default for a scheme file without one', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
$package = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
expect(Scheme::light($this->path, null, 'high')['primary'])->toBe('#123456')
->and(Scheme::light($this->path, null, 'high')['surface'])->toBe($package['contrast']['high']['light']['surface'])
->and(Scheme::light($this->path)['surface'])->toBe($package['light']['surface']);
});
it('has no profile for a single scheme, and loads it as before', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
Scheme::resolveProfileUsing(fn (): string => 'teal');
+21 -2
View File
@@ -38,6 +38,23 @@ it('ships a default scheme that declares every role in both themes', function ()
}
});
it('ships M3\'s medium and high contrast levels beside the standard one', function () {
$scheme = json_decode(File::get(packageCss('tokens/scheme.json')), true);
$css = File::get(packageCss('tokens/scheme.css'));
foreach (['medium', 'high'] as $level) {
foreach (['light', 'dark'] as $theme) {
$declared = declarations($css, "[data-contrast='{$level}'] [data-theme='{$theme}'] {");
expect(array_keys($declared))->toEqual(array_map(fn (string $role): string => "--md-sys-color-{$role}", array_keys($scheme['light'])));
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
expect($declared["--md-sys-color-{$role}"])->toBe($hex);
}
}
}
});
it('turns every role into a Tailwind colour', function () {
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
$theme = File::get(packageCss('tokens/theme.css'));
@@ -58,9 +75,11 @@ it('resolves colour utilities on the element, so a nested data-theme repaints th
->not->toMatch('/--[\w-]+:\s*#/');
});
it('leaves the choice of theme to the head script, never to a media query', function () {
it('leaves the choice of theme and of contrast to the head script, never to a media query', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())->not->toContain('prefers-color-scheme');
expect($file->getContents())
->not->toContain('prefers-color-scheme')
->not->toContain('prefers-contrast');
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff