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:
co-authored by
Claude Fable 5.1
parent
b1fc0c9cfa
commit
4263982369
+112
-19
@@ -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
@@ -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)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user