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
+2 -2
View File
@@ -72,11 +72,11 @@ Generate the scheme from a seed colour. It writes `resources/css/material-scheme
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
### Configuration
+8 -3
View File
@@ -6,7 +6,8 @@
* 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}.
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
*/
import {
@@ -56,9 +57,12 @@ try {
const hex = /^#[0-9a-f]{6}$/i
const Scheme = VARIANTS[input.variant]
const SPECS = ['2021', '2025']
const spec = input.spec ?? '2025'
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(', ')}.`)
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.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.`)
@@ -73,8 +77,9 @@ 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')
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
// M3's original colour, for an application whose palette was generated with it.
const scheme = new Scheme(source, isDark, contrast, spec)
const out = {}
for (const color of colors.allColors) {
+3 -1
View File
@@ -113,7 +113,9 @@ return [
|--------------------------------------------------------------------------
|
| Named schemes an installation can switch between. Each one is a 'label',
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'. Without a
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'; 'spec'
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
| optional too, taken from the command's options when left out. Without a
| seed, `php artisan material:scheme` generates every profile into one
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
| (else the first); the application says which is active with
@@ -44,7 +44,7 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
### Colour profiles
@@ -62,6 +62,7 @@ An installation that switches between several schemes lists them in `config/live
php artisan material:scheme
```
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
File diff suppressed because one or more lines are too long
+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");
+74
View File
@@ -56,6 +56,48 @@ it('generates a different scheme per variant', function () {
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
});
it('generates with the 2021 colour spec when asked, and records it', function () {
$this->artisan('material:scheme', [
'seed' => '#00bc7d',
'--variant' => 'vibrant',
'--success' => '#00d390',
'--warning' => '#fcb700',
'--info' => '#00bafe',
'--spec' => '2021',
'--output' => $this->stylesheet,
])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
expect($scheme['spec'])->toBe('2021')
->and($scheme['dark'])->toMatchArray([
'surface' => '#0b1610',
'primary' => '#00e297',
'on-primary' => '#003822',
'success' => '#2ce19c',
'warning' => '#ffbb16',
'info' => '#80cfff',
])
->and($scheme['light'])->toMatchArray([
'surface' => '#f0fdf2',
'primary' => '#006c46',
'success' => '#006c48',
'warning' => '#7c5800',
'info' => '#00658c',
])
->and(File::get($this->stylesheet))
->toContain('(spec 2021)')
->toContain('php artisan material:scheme "#00bc7d" --variant=vibrant --spec=2021 --success="#00d390" --warning="#fcb700" --info="#00bafe"'."\n");
});
it('refuses an unknown colour spec', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--spec' => '2023', '--output' => $this->stylesheet])
->expectsOutputToContain('Unknown spec "2023". Use one of: 2021, 2025.')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('refuses a seed that is not a colour', function () {
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
->expectsOutputToContain('#rrggbb')
@@ -118,6 +160,38 @@ it('generates every configured profile into one stylesheet keyed by data-scheme'
}
});
it('takes a profile\'s own spec and state colours, and the command\'s for a profile without them', function () {
config([
'livewire-material.profiles' => [
'expressive' => ['seed' => '#00bc7d', 'variant' => 'vibrant'],
'classic' => ['seed' => '#00bc7d', 'variant' => 'vibrant', 'spec' => 2021, 'success' => '#00d390', 'warning' => '#fcb700', 'info' => '#00bafe'],
],
]);
$this->artisan('material:scheme', ['--info' => '#00bafe', '--output' => $this->stylesheet])->assertSuccessful();
$profiles = json_decode(File::get($this->data), true)['profiles'];
expect($profiles['classic'])->spec->toBe('2021')
->and($profiles['classic']['dark'])->toMatchArray(['primary' => '#00e297', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff'])
->and($profiles['expressive'])->spec->toBe('2025')
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
->and(File::get($this->stylesheet))
->toContain('(spec 2025)')
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
->toMatch('/ \* expressive\s+#00bc7d, vibrant\n/');
});
it('names the profile whose spec is unknown', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'spec' => '2019']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "indigo": Unknown spec "2019"')
->assertFailed();
});
it('names the profile a generator error belongs to', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);