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