Add a colour spec choice to material:scheme

The scheme script always generated with material-color-utilities' 2025 spec.
An application whose palette was generated with M3's original 2021 colour
(ReStride) could not reproduce it. The script now takes `spec` ('2025' by
default, or '2021') and refuses anything else, and the command takes
`--spec`, validated before Node runs.

Colour profiles may set their own `spec`, `success`, `warning` and `info`;
without them the command's options apply. The stylesheet header writes out
every option that differs from its default, so the recorded command
regenerates the file, and a profile whose spec differs from the header's
names it. Default output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 17:52:50 +02:00
co-authored by Claude Opus 5
parent db179e6b4e
commit 3a7aa96ec7
7 changed files with 147 additions and 24 deletions
+56 -15
View File
@@ -18,6 +18,7 @@ class SchemeCommand extends Command
protected $signature = 'material:scheme
{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}
{--success=#22a06b : The source of the success colour}
{--warning=#e2a400 : The source of the warning colour}
@@ -29,19 +30,44 @@ class SchemeCommand extends Command
*/
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
/**
* The colour specs Google's colour utilities know. 2025 is M3 Expressive's colour; the library
* falls back to 2021 by itself for the variants 2025 does not define.
*
* @var list<string>
*/
protected const SPECS = ['2021', '2025'];
public function handle(Filesystem $files): int
{
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
$spec = (string) $this->option('spec');
if (! in_array($spec, self::SPECS, true)) {
$this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.');
return self::FAILURE;
}
if (filled($this->argument('seed'))) {
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
$input = [
'seed' => (string) $this->argument('seed'),
'variant' => (string) $this->option('variant'),
'spec' => $spec,
'contrast' => (float) $this->option('contrast'),
'success' => (string) $this->option('success'),
'warning' => (string) $this->option('warning'),
'info' => (string) $this->option('info'),
];
$scheme = $this->generate($input);
if ($scheme === null) {
return self::FAILURE;
}
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $scheme);
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme, $input), $scheme);
}
$profiles = config('livewire-material.profiles');
@@ -61,7 +87,16 @@ class SchemeCommand extends Command
return self::FAILURE;
}
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ");
// 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),
'success' => (string) ($profile['success'] ?? $this->option('success')),
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
'info' => (string) ($profile['info'] ?? $this->option('info')),
], "Profile \"{$name}\": ");
if ($scheme === null) {
return self::FAILURE;
@@ -83,21 +118,15 @@ class SchemeCommand extends Command
/**
* 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
*/
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
protected function generate(array $input, string $context = ''): ?array
{
$result = Process::run([
config('livewire-material.node', 'node'),
__DIR__.'/../../resources/node/scheme.mjs',
json_encode([
'seed' => $seed,
'variant' => $variant,
'contrast' => $contrast,
'success' => $this->option('success'),
'warning' => $this->option('warning'),
'info' => $this->option('info'),
]),
json_encode($input),
]);
if ($result->failed()) {
@@ -130,15 +159,26 @@ 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
*/
protected function stylesheet(array $scheme): string
protected function stylesheet(array $scheme, array $input): string
{
$states = collect(['success', 'warning', 'info'])
->reject(fn (string $state): bool => strtolower($input[$state]) === strtolower((string) $this->getDefinition()->getOption($state)->getDefault()))
->map(fn (string $state): string => sprintf(' --%s="%s"', $state, strtolower($input[$state])))
->implode('');
$command = sprintf(
'php artisan material:scheme "%s" --variant=%s%s',
'php artisan material:scheme "%s" --variant=%s%s%s%s',
$scheme['seed'],
$scheme['variant'],
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
$states,
);
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
@@ -171,10 +211,11 @@ class SchemeCommand extends Command
{
$list = collect($profiles)
->map(fn (array $profile, string $name): string => sprintf(
' * %-12s %s, %s%s',
' * %-12s %s, %s%s%s',
$name,
$profile['seed'],
$profile['variant'],
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
))
->implode("\n");