Add the Material 3 Expressive foundation
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s

Colour, shape, type, elevation and motion as tokens and Tailwind
utilities; `php artisan material:scheme`, which generates an app's colour
roles with Google's material-color-utilities (spec 2025); the theme head
script with light, dark and system and its Alpine store; Google Sans Flex;
every Material Symbol (4,135, outlined and filled) drawn by <x-icon>
without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as
<x-shape>; the x-figure directive; the Toasts concern; DesignGuard for
applications' tests; and a showcase with every token, both themes side by
side and an icon search.

Colour utilities are `@theme inline`, so a section with its own
data-theme repaints; without it they resolve once on :root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 05:24:11 +02:00
co-authored by Claude Opus 5
parent 9b53891a8e
commit b48e879254
8364 changed files with 12720 additions and 28 deletions
+118
View File
@@ -0,0 +1,118 @@
/**
* The source of resources/node/scheme.mjs, which `php artisan material:scheme` runs.
*
* Rebuild with `npm run build:scheme` after changing this file or upgrading
* @material/material-color-utilities; the bundle is committed so applications need
* nothing but `node`. (The published library imports without file extensions, which
* plain Node refuses, so it cannot be run unbundled anyway.)
*
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
*/
import {
argbFromHex,
customColor,
hexFromArgb,
Hct,
MaterialDynamicColors,
SchemeContent,
SchemeExpressive,
SchemeFidelity,
SchemeFruitSalad,
SchemeMonochrome,
SchemeNeutral,
SchemeRainbow,
SchemeTonalSpot,
SchemeVibrant,
} from '@material/material-color-utilities'
const VARIANTS = {
'tonal-spot': SchemeTonalSpot,
vibrant: SchemeVibrant,
expressive: SchemeExpressive,
neutral: SchemeNeutral,
fidelity: SchemeFidelity,
content: SchemeContent,
monochrome: SchemeMonochrome,
rainbow: SchemeRainbow,
'fruit-salad': SchemeFruitSalad,
}
// Key colours seed the palettes; they are not roles a stylesheet paints with.
const NOT_ROLES = /_palette_key_color$/
function fail(message) {
process.stderr.write(`${message}\n`)
process.exit(1)
}
let input
try {
input = JSON.parse(process.argv[2] ?? '')
} catch {
fail('Expected one JSON argument: {"seed": "#rrggbb", "variant": "tonal-spot", ...}')
}
const hex = /^#[0-9a-f]{6}$/i
const Scheme = VARIANTS[input.variant]
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
for (const state of ['success', 'warning', 'info']) {
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
}
const contrast = Number(input.contrast ?? 0)
if (!(contrast >= -1 && contrast <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
const source = Hct.fromInt(argbFromHex(input.seed))
const colors = new MaterialDynamicColors()
function roles(isDark) {
// 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, …).
const scheme = new Scheme(source, isDark, contrast, '2025')
const out = {}
for (const color of colors.allColors) {
if (color && !NOT_ROLES.test(color.name)) {
out[color.name.replaceAll('_', '-')] = hexFromArgb(color.getArgb(scheme))
}
}
return { scheme, out }
}
const light = roles(false)
const dark = roles(true)
// 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', 'success', 'warning', 'info']) {
light.out[`inverse-${state}`] = dark.out[state]
dark.out[`inverse-${state}`] = light.out[state]
}
process.stdout.write(JSON.stringify({
seed: input.seed.toLowerCase(),
variant: input.variant,
spec: light.scheme.specVersion,
contrast,
light: light.out,
dark: dark.out,
}))