Merge branch 'worktree-agent-a9537eabbf38e0608'
# Conflicts: # resources/views/components/scheme-picker.blade.php # resources/views/components/theme-script.blade.php # resources/views/showcase/sections/colour.blade.php # tests/Feature/Components/ThemeScriptTest.php
This commit is contained in:
@@ -69,6 +69,19 @@ it('draws floating and docked toolbars', function () {
|
||||
->not->toContain('data-toolbar-group');
|
||||
});
|
||||
|
||||
it('offers M3\'s three contrast levels, marking the one in force', function () {
|
||||
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
|
||||
->toContain('data-theme-toggle="contrast"')
|
||||
->toContain('role="radiogroup"')
|
||||
->toContain('aria-label="Contrast"')
|
||||
->toContain('data-contrast-option="standard"')
|
||||
->toContain('data-contrast-option="medium"')
|
||||
->toContain('data-contrast-option="high"')
|
||||
->toContain("\$store.theme.setContrast('high')")
|
||||
// The row follows the resolved level, so the operating system's shows under `system`.
|
||||
->toContain("(\$store.theme.resolvedContrast === 'medium').toString()");
|
||||
});
|
||||
|
||||
it('switches the theme through the store in three shapes', function () {
|
||||
expect((string) $this->blade('<x-theme-toggle />'))
|
||||
->toContain('data-theme-toggle="toggle"')
|
||||
|
||||
@@ -35,11 +35,37 @@ it('draws a radio per generated profile, bound, named and previewing on change',
|
||||
it('puts its other attributes on the group and the binding on the radios', function () {
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />');
|
||||
|
||||
expect($html)->toMatch('/<fieldset x-data data-scheme-picker class="min-w-0 mt-4" data-test="color-profile">/')
|
||||
expect($html)->toMatch('/<fieldset\s+x-data="\{ swatches: [^"]+\}"\s+data-scheme-picker\s+class="min-w-0 mt-4" data-test="color-profile"\s*>/')
|
||||
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
|
||||
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
|
||||
});
|
||||
|
||||
it('swaps the dots to the contrast level on screen', function () {
|
||||
File::put($this->path, json_encode([
|
||||
'default' => 'indigo',
|
||||
'profiles' => [
|
||||
'indigo' => [
|
||||
'label' => 'Indigo',
|
||||
'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'],
|
||||
'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7'],
|
||||
'contrast' => ['high' => ['light' => ['primary' => '#241bb0'], 'dark' => ['primary' => '#eeeaff']]],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
|
||||
|
||||
expect($html)
|
||||
// Standard is the inline pair, so a dot is painted before Alpine runs.
|
||||
->toContain('style="--swatch-light: #4f46e5; --swatch-dark: #c0c1ff"')
|
||||
->toContain('\u0022standard\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#4f46e5\u0022')
|
||||
->toContain('\u0022high\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#241bb0\u0022')
|
||||
// A level the file does not carry repeats the standard colours rather than none.
|
||||
->toContain('\u0022medium\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#4f46e5\u0022')
|
||||
->toContain('swatches[\'indigo\'][$store.theme.resolvedContrast].light[\'primary\']')
|
||||
->toContain('swatches[\'indigo\'][$store.theme.resolvedContrast].dark[\'tertiary\']');
|
||||
});
|
||||
|
||||
it('keeps its inline styles to the scheme file\'s checked colours', function () {
|
||||
File::put($this->path, json_encode([
|
||||
'default' => 'indigo',
|
||||
@@ -48,10 +74,12 @@ it('keeps its inline styles to the scheme file\'s checked colours', function ()
|
||||
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
|
||||
|
||||
preg_match_all('/style="([^"]*)"/', $html, $styles);
|
||||
preg_match_all('/\sstyle="([^"]*)"/', $html, $styles);
|
||||
|
||||
expect($styles[1])->not->toBeEmpty()
|
||||
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/');
|
||||
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/')
|
||||
// The colours Alpine swaps in are the same filtered ones, never the file's own text.
|
||||
->and($html)->not->toContain('url(');
|
||||
});
|
||||
|
||||
it('shows a validation message for the bound property instead of the hint', function () {
|
||||
|
||||
@@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
it('follows the operating system until the visitor chooses, by default', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
|
||||
->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"contrast":{"default":"system","key":"material-contrast"},"motion":null,"rail":{"default":"expanded","key":"material-rail"}})', false);
|
||||
});
|
||||
|
||||
it('resolves the contrast level before the first paint, and leaves standard unwritten', function () {
|
||||
config(['livewire-material.theme.contrast' => ['default' => 'high', 'storage_key' => 'sealshare-contrast']]);
|
||||
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"contrast":{"default":"high","key":"sealshare-contrast"}', false)
|
||||
->assertSee("window.matchMedia('(prefers-contrast: more)')", false)
|
||||
->assertSee("var level = current === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : current;", false)
|
||||
->assertSee("root.setAttribute('data-contrast', level);", false)
|
||||
->assertSee("root.removeAttribute('data-contrast');", false)
|
||||
->assertSee("root.setAttribute('data-contrast-choice', contrast);", false)
|
||||
->assertSee("contrastMedia.addEventListener('change', applyContrast);", false);
|
||||
|
||||
config(['livewire-material.theme.contrast.default' => 'extreme']);
|
||||
|
||||
$this->blade('<x-theme-script />')->assertSee('"contrast":{"default":"system"', false);
|
||||
});
|
||||
|
||||
it('writes the standard motion scheme to <html>, and nothing for the expressive one', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"motion":null', false)
|
||||
->assertDontSee("root.setAttribute('data-motion', 'standard')", false);
|
||||
|
||||
config(['livewire-material.motion.scheme' => 'standard']);
|
||||
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"motion":"standard"', false)
|
||||
->assertSee("root.setAttribute('data-motion', settings.motion);", false);
|
||||
});
|
||||
|
||||
it('takes the application\'s default, key and legacy keys', function () {
|
||||
@@ -42,7 +71,7 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
|
||||
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee("document.addEventListener('livewire:navigating'", false)
|
||||
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-auto', 'data-rail-key']", false)
|
||||
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', 'data-rail-auto', 'data-rail-key']", false)
|
||||
->assertSee('event.detail.onSwap(', false);
|
||||
});
|
||||
|
||||
@@ -79,29 +108,50 @@ it('paints the theme-color meta in the resolved theme\'s surface when asked', fu
|
||||
config(['livewire-material.theme.meta' => true]);
|
||||
|
||||
$scheme = Scheme::load();
|
||||
$surfaces = fn (string $level): string => sprintf(
|
||||
'{"light":"%s","dark":"%s"}',
|
||||
Scheme::load(contrast: $level)['light']['surface'],
|
||||
Scheme::load(contrast: $level)['dark']['surface'],
|
||||
);
|
||||
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","profiles":{}}', false)
|
||||
->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","contrast":{"medium":'.$surfaces('medium').',"high":'.$surfaces('high').'},"profiles":{}}', false)
|
||||
->assertSee('document.head.querySelectorAll(\'meta[name="theme-color"]:not([media])\')', false)
|
||||
->assertSee("new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });", false)
|
||||
->assertSee("new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme', 'data-contrast'] });", false)
|
||||
->assertSee("document.addEventListener('livewire:navigated', paintThemeColor);", false);
|
||||
|
||||
// The profile on <html> picks the set of surfaces, the level in data-contrast the pair.
|
||||
// (M3 moves the ink rather than the page, so a scheme's surface may read the same at every
|
||||
// level; the meta still takes the level's own, whatever the generator made of it.)
|
||||
expect((string) $this->blade('<x-theme-script />'))
|
||||
->toContain('var surfaces = Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta;')
|
||||
->toContain('var colour = (Object.prototype.hasOwnProperty.call(surfaces.contrast, level) ? surfaces.contrast[level] : surfaces)[theme];');
|
||||
});
|
||||
|
||||
it('gives the theme-color meta every profile\'s surfaces, the active one\'s first', function () {
|
||||
it('gives the theme-color meta every profile\'s surfaces, at every contrast level', function () {
|
||||
$path = sys_get_temp_dir().'/theme-script-meta-'.uniqid().'.json';
|
||||
File::put($path, json_encode([
|
||||
'default' => 'indigo',
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'light' => ['surface' => '#fbf8ff'], 'dark' => ['surface' => '#12131a']],
|
||||
'indigo' => [
|
||||
'label' => 'Indigo',
|
||||
'light' => ['surface' => '#fbf8ff'],
|
||||
'dark' => ['surface' => '#12131a'],
|
||||
'contrast' => ['high' => ['light' => ['surface' => '#ffffff'], 'dark' => ['surface' => '#000000']]],
|
||||
],
|
||||
'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']],
|
||||
],
|
||||
]));
|
||||
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
Scheme::resolveProfileUsing(fn (): string => 'indigo');
|
||||
|
||||
try {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"meta":{"light":"#f4fbf8","dark":"#0e1513","profiles":{"indigo":{"light":"#fbf8ff","dark":"#12131a"},"teal":{"light":"#f4fbf8","dark":"#0e1513"}}}', false);
|
||||
// The active profile first, then every profile, each with the levels it generated;
|
||||
// a profile without a level keeps its standard surfaces there.
|
||||
->assertSee('"meta":{"light":"#fbf8ff","dark":"#12131a","contrast":{"medium":{"light":"#fbf8ff","dark":"#12131a"},"high":{"light":"#ffffff","dark":"#000000"}},"profiles":{', false)
|
||||
->assertSee('"indigo":{"light":"#fbf8ff","dark":"#12131a","contrast":{"medium":{"light":"#fbf8ff","dark":"#12131a"},"high":{"light":"#ffffff","dark":"#000000"}}}', false)
|
||||
->assertSee('"teal":{"light":"#f4fbf8","dark":"#0e1513","contrast":{"medium":{"light":"#f4fbf8","dark":"#0e1513"},"high":{"light":"#f4fbf8","dark":"#0e1513"}}}', false);
|
||||
} finally {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
File::delete($path);
|
||||
|
||||
@@ -86,6 +86,23 @@ function withPackageMailComponents(): void
|
||||
app()->forgetInstance(Markdown::class);
|
||||
}
|
||||
|
||||
it('stays on the standard contrast level, which is the one a mail client can show', function () {
|
||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
|
||||
'dark' => ['primary' => '#abcdef'],
|
||||
'contrast' => [
|
||||
'standard' => 0,
|
||||
'high' => ['light' => ['primary' => '#000000', 'on-primary' => '#ffffff'], 'dark' => ['primary' => '#ffffff']],
|
||||
],
|
||||
]));
|
||||
|
||||
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||
|
||||
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))
|
||||
->toContain('background-color: #123456')
|
||||
->not->toContain('background-color: #000000');
|
||||
});
|
||||
|
||||
it('inlines the application\'s scheme onto the mail', function () {
|
||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
|
||||
|
||||
@@ -3,6 +3,37 @@
|
||||
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);
|
||||
@@ -23,6 +54,8 @@ it('writes the scheme as a stylesheet and as data', function () {
|
||||
->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'])
|
||||
@@ -41,6 +74,137 @@ it('writes the scheme as a stylesheet and as data', function () {
|
||||
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();
|
||||
@@ -177,7 +341,8 @@ it('takes a profile\'s own spec and state colours, and the command\'s for a prof
|
||||
->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')
|
||||
// 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/')
|
||||
|
||||
@@ -19,6 +19,11 @@ function writeProfiles(string $path, array $primaries, string $default): void
|
||||
'contrast' => 0,
|
||||
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
|
||||
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
|
||||
'contrast' => [
|
||||
'standard' => 0,
|
||||
'medium' => ['light' => ['primary' => '#332f8a'], 'dark' => ['surface' => '#0a0a0a']],
|
||||
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff', 'surface' => '#000000']],
|
||||
],
|
||||
])->all();
|
||||
|
||||
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
|
||||
@@ -69,6 +74,29 @@ it('loads a profile by name, whatever is active', function () {
|
||||
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
|
||||
});
|
||||
|
||||
it('loads a contrast level, falling back to the standard roles it does not name', function () {
|
||||
expect(Scheme::load($this->path, 'teal', 'high')['light']['primary'])->toBe('#000000')
|
||||
->and(Scheme::load($this->path, 'teal', 'high')['dark']['surface'])->toBe('#000000')
|
||||
// The level names no surface in light, so the standard one stands.
|
||||
->and(Scheme::light($this->path, 'teal', 'high')['surface'])->toBe('#fafafa')
|
||||
->and(Scheme::load($this->path, 'teal', 'medium')['light']['primary'])->toBe('#332f8a')
|
||||
->and(Scheme::load($this->path, 'teal')['light']['primary'])->toBe('#00897b')
|
||||
// A level nobody generated, and a name that is not one, are the standard scheme.
|
||||
->and(Scheme::load($this->path, 'teal', 'extreme')['light']['primary'])->toBe('#00897b')
|
||||
->and(Scheme::profiles($this->path, 'high')['rose']['light']['primary'])->toBe('#000000')
|
||||
->and(Scheme::profiles($this->path)['rose']['light']['primary'])->toBe('#c2185b');
|
||||
});
|
||||
|
||||
it('draws a high contrast level from the package default for a scheme file without one', function () {
|
||||
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||
|
||||
$package = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
|
||||
|
||||
expect(Scheme::light($this->path, null, 'high')['primary'])->toBe('#123456')
|
||||
->and(Scheme::light($this->path, null, 'high')['surface'])->toBe($package['contrast']['high']['light']['surface'])
|
||||
->and(Scheme::light($this->path)['surface'])->toBe($package['light']['surface']);
|
||||
});
|
||||
|
||||
it('has no profile for a single scheme, and loads it as before', function () {
|
||||
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
|
||||
@@ -66,6 +66,23 @@ it('ships a default scheme that declares every role in both themes', function ()
|
||||
}
|
||||
});
|
||||
|
||||
it('ships M3\'s medium and high contrast levels beside the standard one', function () {
|
||||
$scheme = json_decode(File::get(packageCss('tokens/scheme.json')), true);
|
||||
$css = File::get(packageCss('tokens/scheme.css'));
|
||||
|
||||
foreach (['medium', 'high'] as $level) {
|
||||
foreach (['light', 'dark'] as $theme) {
|
||||
$declared = declarations($css, "[data-contrast='{$level}'] [data-theme='{$theme}'] {");
|
||||
|
||||
expect(array_keys($declared))->toEqual(array_map(fn (string $role): string => "--md-sys-color-{$role}", array_keys($scheme['light'])));
|
||||
|
||||
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
|
||||
expect($declared["--md-sys-color-{$role}"])->toBe($hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('turns every role into a Tailwind colour', function () {
|
||||
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
|
||||
$theme = File::get(packageCss('tokens/theme.css'));
|
||||
@@ -86,9 +103,11 @@ it('resolves colour utilities on the element, so a nested data-theme repaints th
|
||||
->not->toMatch('/--[\w-]+:\s*#/');
|
||||
});
|
||||
|
||||
it('leaves the choice of theme to the head script, never to a media query', function () {
|
||||
it('leaves the choice of theme and of contrast to the head script, never to a media query', function () {
|
||||
foreach (File::allFiles(packageCss()) as $file) {
|
||||
expect($file->getContents())->not->toContain('prefers-color-scheme');
|
||||
expect($file->getContents())
|
||||
->not->toContain('prefers-color-scheme')
|
||||
->not->toContain('prefers-contrast');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user