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.
*