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
385 lines
17 KiB
PHP
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();
|
|
});
|