Files
Andreas Reinhold / reiniandClaude Fable 5.1 4263982369 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
2026-09-14 05:30:48 +02:00

385 lines
17 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* The hue of a #rrggbb colour, in degrees: what harmonisation moves.
*/
function schemeHue(string $hex): float
{
[$r, $g, $b] = array_map(fn (string $channel): float => hexdec($channel) / 255, str_split(ltrim($hex, '#'), 2));
$chroma = max($r, $g, $b) - min($r, $g, $b);
if ($chroma === 0.0) {
return 0.0;
}
$sector = match (max($r, $g, $b)) {
$r => fmod(($g - $b) / $chroma, 6),
$g => (($b - $r) / $chroma) + 2,
default => (($r - $g) / $chroma) + 4,
};
return fmod(($sector * 60) + 360, 360);
}
/**
* How far apart two colours are on the hue circle, the short way round.
*/
function schemeHueDistance(string $one, string $other): float
{
$apart = abs(schemeHue($one) - schemeHue($other));
return min($apart, 360 - $apart);
}
beforeEach(function () {
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
$this->data = Str::replaceLast('.css', '.json', $this->stylesheet);
});
afterEach(function () {
File::delete([$this->stylesheet, $this->data]);
});
it('writes the scheme as a stylesheet and as data', function () {
$this->artisan('material:scheme', ['seed' => '#4F46E5', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
expect($scheme)
->seed->toBe('#4f46e5')
->variant->toBe('tonal-spot')
->spec->toBe('2025')
->harmonize->toBeFalse()
->and($scheme['contrast']['standard'])->toBe(0)
->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light']))
->and($scheme['light'])->toHaveKeys(['primary', 'on-primary', 'tertiary-container', 'surface-container-high', 'outline-variant', 'success', 'on-warning-container', 'info-container'])
->and($scheme['light']['surface'])->not->toBe($scheme['dark']['surface'])
->and($scheme['light']['inverse-success'])->toBe($scheme['dark']['success']);
foreach (['light' => "[data-theme='light']", 'dark' => "[data-theme='dark']"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};");
foreach ($scheme[$theme] as $role => $hex) {
expect($block)->toContain("--md-sys-color-{$role}: {$hex};");
}
}
expect($stylesheet)->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot');
});
it('writes M3\'s three contrast levels for both themes, keyed on data-contrast', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
// The standard level keeps the 1.x shape at the top; the other two sit under `contrast`.
expect(array_keys($scheme['contrast']))->toBe(['standard', 'medium', 'high'])
->and(array_keys($scheme['contrast']['medium']['light']))->toEqual(array_keys($scheme['light']))
->and(array_keys($scheme['contrast']['high']['dark']))->toEqual(array_keys($scheme['dark']));
foreach (['medium', 'high'] as $level) {
foreach (['light' => "[data-contrast='{$level}'] [data-theme='light'] {", 'dark' => "[data-contrast='{$level}'] [data-theme='dark'] {"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};");
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
expect($block)->toContain("--md-sys-color-{$role}: {$hex};");
}
}
expect($stylesheet)
->toContain("[data-contrast='{$level}'],\n[data-contrast='{$level}'][data-theme='light'],")
->toContain("[data-contrast='{$level}'][data-theme='dark'],");
}
// Every level differs from the one below it, in both themes and for the state colours too.
foreach (['light', 'dark'] as $theme) {
foreach (['on-surface-variant', 'outline', 'success', 'info', 'success-container'] as $role) {
expect($scheme['contrast']['medium'][$theme][$role])
->not->toBe($scheme[$theme][$role], "medium {$theme} {$role}")
->not->toBe($scheme['contrast']['high'][$theme][$role], "high {$theme} {$role}");
}
// The state colours are dynamic colours now, so the level reaches them as it does a
// built-in role. (A container whose tone already clears the level's ratio stays put,
// exactly as error's does — hence the pairs rather than every one of the four.)
foreach (['success', 'warning', 'info'] as $state) {
foreach ([$state, "on-{$state}", "on-{$state}-container"] as $role) {
expect($scheme['contrast']['high'][$theme][$role])->not->toBe($scheme[$theme][$role], "high {$theme} {$role}");
}
}
}
expect($stylesheet)->not->toContain('prefers-contrast');
});
it('puts a level\'s nested light blocks after the dark ones of the level below', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$stylesheet = File::get($this->stylesheet);
$at = fn (string $selector): int => strpos($stylesheet, $selector);
// A light panel inside a dark high-contrast page: its rule carries one attribute more than
// the plain dark block and stands after it, so it wins on specificity and on order.
expect($at("[data-theme='dark'] {"))->toBeLessThan($at("[data-contrast='high'] [data-theme='light'] {"))
->and($at("[data-contrast='medium'] [data-theme='light'] {"))->toBeLessThan($at("[data-contrast='medium'][data-theme='dark'],"))
->and($at("[data-contrast='medium'][data-theme='dark'],"))->toBeLessThan($at("[data-contrast='high'],"));
});
it('generates the levels for every profile, under its own data-scheme', function () {
config(['livewire-material.profiles' => [
'indigo' => ['seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['seed' => '#00897b', 'variant' => 'vibrant'],
]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
foreach ($scheme['profiles'] as $name => $profile) {
foreach (['medium', 'high'] as $level) {
$selector = "[data-scheme='{$name}'][data-contrast='{$level}'] [data-theme='dark'] {";
expect(Str::of($stylesheet)->after($selector)->before('}')->toString())
->toContain("--md-sys-color-primary: {$profile['contrast'][$level]['dark']['primary']};")
->and($profile['contrast'][$level]['light']['success'])->not->toBe($profile['light']['success']);
}
}
// The default profile's plain blocks come first, its levels next, then every profile's.
expect(strpos($stylesheet, "[data-contrast='high'],"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
});
it('refuses a standard contrast level at or above the medium one', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.5', '--output' => $this->stylesheet])
->expectsOutputToContain('Medium (0.5) and high (1) are always generated')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'contrast' => 1]]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "indigo": The contrast level 1')
->assertFailed();
});
it('keeps a standard level below medium, and records it in the header', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.3', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
expect($scheme['contrast']['standard'])->toBe(0.3)
->and($scheme['light']['primary'])->not->toBe($scheme['contrast']['medium']['light']['primary'])
->and(File::get($this->stylesheet))->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot --contrast=0.3');
});
it('harmonises the state colours towards the seed only when asked', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])->assertSuccessful();
$plain = json_decode(File::get($this->data), true);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--harmonize' => true, '--output' => $this->stylesheet])->assertSuccessful();
$harmonized = json_decode(File::get($this->data), true);
expect($harmonized['harmonize'])->toBeTrue()
->and($plain['harmonize'])->toBeFalse()
->and(File::get($this->stylesheet))->toContain('--variant=tonal-spot --harmonize');
foreach (['success', 'warning', 'info'] as $state) {
expect(schemeHueDistance($harmonized['light'][$state], '#4f46e5'))
->toBeLessThan(schemeHueDistance($plain['light'][$state], '#4f46e5'), "{$state} moved towards the seed")
->and($harmonized['contrast']['high']['dark'][$state])->not->toBe($plain['contrast']['high']['dark'][$state]);
}
});
it('generates a different scheme per variant', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
->assertSuccessful();
$vibrant = json_decode(File::get($this->data), true);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$tonalSpot = json_decode(File::get($this->data), true);
expect($vibrant['variant'])->toBe('vibrant')
->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')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('refuses an unknown variant', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'loud', '--output' => $this->stylesheet])
->expectsOutputToContain('Unknown variant "loud"')
->assertFailed();
});
it('fails without writing anything when node cannot run', function () {
config(['livewire-material.node' => '/nonexistent/node']);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('generates every configured profile into one stylesheet keyed by data-scheme', function () {
config([
'livewire-material.profiles' => [
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
'graphite' => ['seed' => '#5f6368', 'variant' => 'neutral'],
],
'livewire-material.profile' => 'teal',
]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
expect($scheme['default'])->toBe('teal')
->and(array_keys($scheme['profiles']))->toBe(['indigo', 'teal', 'graphite'])
->and($scheme['profiles']['graphite'])->label->toBe('Graphite')->variant->toBe('neutral')
->and($scheme['profiles']['indigo']['light']['primary'])->not->toBe($scheme['profiles']['teal']['light']['primary'])
// The top level is the default profile, as a 1.0 reader expects.
->and($scheme['light'])->toBe($scheme['profiles']['teal']['light'])
->and($scheme['dark'])->toBe($scheme['profiles']['teal']['dark'])
->and($scheme['seed'])->toBe('#00897b')
->and($stylesheet)->toContain('php artisan material:scheme'."\n");
// The default's plain blocks come first, so a profile's single-attribute selector follows :root.
expect(strpos($stylesheet, ":root,\n[data-theme='light'] {"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
foreach ($scheme['profiles'] as $name => $profile) {
foreach (['light' => "[data-scheme='{$name}'] [data-theme='light'] {", 'dark' => "[data-scheme='{$name}'] [data-theme='dark'] {"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};")
->toContain("--md-sys-color-primary: {$profile[$theme]['primary']};")
->toContain("--md-sys-color-surface: {$profile[$theme]['surface']};");
}
}
});
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')
// The state colours follow the spec now, so the same source is not the same colour.
->and($profiles['expressive']['dark']['info'])->not->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']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "broken"')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('asks for a seed or profiles when it has neither', function () {
config(['livewire-material.profiles' => []]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('livewire-material.profiles')
->assertFailed();
});
it('refuses a profile name that cannot be an attribute value', function () {
config(['livewire-material.profiles' => ['Ocean Blue' => ['seed' => '#0b57d0']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('"Ocean Blue" is not')
->assertFailed();
});