Add colour profiles

An application lists named schemes in livewire-material.profiles, and
material:scheme without a seed generates them all into one stylesheet:
the default profile as the plain blocks, every profile under
<html data-scheme>, with descendant selectors so a nested data-theme
panel keeps the page's profile. The JSON keeps the 1.0 top level.

Scheme::resolveProfileUsing() lets the application name the active
profile; it is asked on every use and falls back to the default for an
unknown name or a resolver that throws. The head script writes it before
the first paint and keeps it through wire:navigate, and the mail theme
and the error pages' fallback draw it. <x-scheme-picker> chooses one and
previews it through $store.theme.previewScheme(); the showcase previews
the Workbench's profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 14:35:49 +02:00
co-authored by Claude Opus 5
parent 7798352cfc
commit fb42f004b1
22 changed files with 2006 additions and 47 deletions
@@ -0,0 +1,60 @@
<?php
use Illuminate\Support\Facades\File;
beforeEach(function () {
$this->path = sys_get_temp_dir().'/scheme-picker-'.uniqid().'.json';
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']],
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b', 'secondary' => '#4a635f', 'tertiary' => '#456179'], 'dark' => ['primary' => '#80cbc4', 'secondary' => '#b1ccc6', 'tertiary' => '#adcae5']],
],
]));
config(['livewire-material.scheme' => $this->path]);
});
afterEach(function () {
File::delete($this->path);
});
it('draws a radio per generated profile, bound, named and previewing on change', function () {
$html = (string) $this->blade('<x-scheme-picker label="Colour profile" hint="Applies after saving" wire:model="colorProfile" />');
expect($html)
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Colour profile</legend>')
->toContain('Applies after saving')
->toContain('data-scheme-option="indigo"')
->toContain('data-scheme-option="teal"')
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
->toContain('>Teal</span>')
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
});
it('keeps its inline styles to the scheme file\'s checked colours', function () {
File::put($this->path, json_encode([
'default' => 'indigo',
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => ['primary' => 'red; background: url(x)'], 'dark' => []]],
]));
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
preg_match_all('/style="([^"]*)"/', $html, $styles);
expect($styles[1])->not->toBeEmpty()
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/');
});
it('shows a validation message for the bound property instead of the hint', function () {
$this->withViewErrors(['colorProfile' => ['Choose one of the colour profiles.']])
->blade('<x-scheme-picker wire:model="colorProfile" hint="Applies after saving" />')
->assertSee('Choose one of the colour profiles.')
->assertDontSee('Applies after saving');
});
it('renders nothing for a single scheme', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => []]));
expect(trim((string) $this->blade('<x-scheme-picker wire:model="colorProfile" />')))->toBe('');
});
+27 -2
View File
@@ -1,8 +1,11 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Scheme;
it('follows the operating system until the visitor chooses, by default', function () {
$this->blade('<x-theme-script />')
->assertSee('({"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
});
it('takes the application\'s default, key and legacy keys', function () {
@@ -39,6 +42,28 @@ 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-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
->assertSee('event.detail.onSwap(', false);
});
it('names the active colour profile for <html data-scheme>, and none for a single scheme', function () {
$path = sys_get_temp_dir().'/theme-script-profiles-'.uniqid().'.json';
File::put($path, json_encode([
'default' => 'indigo',
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => [], 'dark' => []], 'teal' => ['label' => 'Teal', 'light' => [], 'dark' => []]],
]));
config(['livewire-material.scheme' => $path]);
try {
$this->blade('<x-theme-script />')
->assertSee('({"scheme":"indigo",', false)
->assertSee("root.setAttribute('data-scheme', settings.scheme);", false);
Scheme::resolveProfileUsing(fn (): string => 'teal');
$this->blade('<x-theme-script />')->assertSee('({"scheme":"teal",', false);
} finally {
Scheme::resolveProfileUsing(null);
File::delete($path);
}
});
+28
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\Scheme;
beforeEach(function () {
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
@@ -128,6 +129,33 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
->assertDontSee('/build/', false);
});
it('draws the active colour profile when the Vite manifest is missing', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'],
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b'], 'dark' => ['primary' => '#80cbc4']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
Scheme::resolveProfileUsing(fn (): string => 'teal');
try {
$this->get('/abort/500')
->assertStatus(500)
->assertSee('--md-sys-color-primary: #00897b;', false)
->assertSee('--md-sys-color-primary: #80cbc4;', false)
->assertDontSee('#4f46e5', false);
} finally {
Scheme::resolveProfileUsing(null);
}
});
it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
withoutSkeletonErrorViews();
+22
View File
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\Scheme;
class ThemedProbeMail extends Mailable
{
@@ -113,6 +114,27 @@ it('inlines the application\'s scheme onto the mail', function () {
->not->toContain('color-mix');
});
it('inlines the active colour profile onto the mail', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'],
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
'rose' => ['label' => 'Rose', 'light' => ['primary' => '#c2185b'], 'dark' => ['primary' => '#ffb0c8']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
Scheme::resolveProfileUsing(fn (): string => 'rose');
try {
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))->toContain('background-color: #c2185b');
} finally {
Scheme::resolveProfileUsing(null);
}
});
it('takes the theme from a mailable too', function () {
config(['mail.markdown.theme' => 'default']);
+65
View File
@@ -78,3 +78,68 @@ it('fails without writing anything when node cannot run', function () {
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('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();
});
+80
View File
@@ -0,0 +1,80 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Scheme;
/**
* A scheme file with colour profiles, as `material:scheme` writes one from config.
*
* @param array<string, string> $primaries profile => light primary
*/
function writeProfiles(string $path, array $primaries, string $default): void
{
$profiles = collect($primaries)->map(fn (string $primary, string $name): array => [
'label' => Str::headline($name),
'seed' => $primary,
'variant' => 'vibrant',
'spec' => '2025',
'contrast' => 0,
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
])->all();
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
}
beforeEach(function () {
$this->path = sys_get_temp_dir().'/material-profiles-'.Str::random(8).'.json';
writeProfiles($this->path, ['indigo' => '#4f46e5', 'teal' => '#00897b', 'rose' => '#c2185b'], 'indigo');
});
afterEach(function () {
Scheme::resolveProfileUsing(null);
File::delete($this->path);
});
it('lists the generated profiles with their labels and roles, filling missing roles from the default', function () {
$profiles = Scheme::profiles($this->path);
expect(array_keys($profiles))->toBe(['indigo', 'teal', 'rose'])
->and($profiles['teal']['label'])->toBe('Teal')
->and($profiles['teal']['light']['primary'])->toBe('#00897b')
->and($profiles['teal']['light'])->toHaveKey('on-surface-variant')
->and(Scheme::profiles(sys_get_temp_dir().'/missing-scheme.json'))->toBe([]);
});
it('follows the resolver to the active profile, and falls back to the default', function () {
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::profile($this->path))->toBe('teal')
->and(Scheme::light($this->path)['primary'])->toBe('#00897b')
->and(Scheme::load($this->path)['dark']['surface'])->toBe('#111111');
Scheme::resolveProfileUsing(fn (): string => 'ocean');
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn (): ?string => null);
expect(Scheme::profile($this->path))->toBe('indigo');
Scheme::resolveProfileUsing(fn () => throw new RuntimeException('The database is down.'));
expect(Scheme::profile($this->path))->toBe('indigo');
});
it('loads a profile by name, whatever is active', function () {
Scheme::resolveProfileUsing(fn (): string => 'teal');
expect(Scheme::light($this->path, 'rose')['primary'])->toBe('#c2185b')
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
});
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');
expect(Scheme::profile($this->path))->toBeNull()
->and(Scheme::profiles($this->path))->toBe([])
->and(Scheme::light($this->path)['primary'])->toBe('#123456')
->and(Scheme::light($this->path))->toHaveKey('on-surface');
});