diff --git a/resources/views/components/scheme-picker.blade.php b/resources/views/components/scheme-picker.blade.php index 1c9172c6..dadb7c96 100644 --- a/resources/views/components/scheme-picker.blade.php +++ b/resources/views/components/scheme-picker.blade.php @@ -8,7 +8,10 @@ (`$store.theme.previewScheme`); storing it — and telling `Scheme::resolveProfileUsing()` — is the application's. The dots are the only colours not drawn from tokens: they show other profiles than the page's, so they are custom properties set inline from the scheme file's - checked hexes, light or dark with the page. Without profiles it renders nothing. + checked hexes, light or dark with the page. The inline pair is the standard contrast level, + which is what the server can know; Alpine swaps in the level on screen + (`$store.theme.resolvedContrast`) so a dot never promises a colour the page would not paint. + Without profiles it renders nothing. Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated profile). A validation message for the bound property replaces the hint. --}} @@ -26,10 +29,32 @@ $name ??= $model ?: 'scheme'; $errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null); $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; + $roles = ['primary', 'secondary', 'tertiary']; + + // profile => level => theme => role => hex, for the dots. Every level is filled — a profile + // the caller passed in by hand, or a scheme file without the levels, repeats its standard + // colours — so the binding below never asks whether a level is there. + $swatches = []; + + foreach (\NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS as $level) { + $generated = $level === 'standard' ? [] : \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(contrast: $level); + + foreach ($profiles as $profile => $scheme) { + foreach (['light', 'dark'] as $theme) { + foreach ($roles as $role) { + $swatches[$profile][$level][$theme][$role] = ($generated[$profile] ?? $scheme)[$theme][$role]; + } + } + } + } @endphp @if ($profiles !== []) -
whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}> +
whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }} + > @if (filled($label)) {{ $label }} @endif @@ -50,10 +75,14 @@ /> diff --git a/resources/views/components/theme-toggle.blade.php b/resources/views/components/theme-toggle.blade.php index 8919a11b..47181f7a 100644 --- a/resources/views/components/theme-toggle.blade.php +++ b/resources/views/components/theme-toggle.blade.php @@ -7,6 +7,10 @@ "Dark theme", pressed while dark. - `cycle`: an icon button that steps light → dark → system, showing the choice it is on. - `picker`: M3's segmented buttons for the three choices, for a settings page. + - `contrast`: the same row for M3's three contrast levels — standard, medium (3:1) and high + (7:1), the three the scheme is generated in. The row marks the level in force + (`resolvedContrast`), so the operating system's setting shows while the choice is + `system`, and choosing one is an explicit choice from then on. The theme is the visitor's, known only in the browser, so the parts that depend on it wait for Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}} @@ -16,10 +20,37 @@ ]) @php - $mode = in_array($mode, ['toggle', 'cycle', 'picker'], true) ? $mode : 'toggle'; + $mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle'; @endphp -@if ($mode === 'picker') +@if ($mode === 'contrast') +
class(['inline-flex h-10 rounded-corner-full border border-outline']) }} + > + @foreach (['standard' => ['Standard', 'contrast'], 'medium' => ['Medium', 'contrast_circle'], 'high' => ['High', 'contrast_square']] as $level => [$text, $icon]) + + @endforeach +
+@elseif ($mode === 'picker')
['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'], ]; + // The roles a contrast level moves most: the page keeps its surface, the ink and the + // outlines carry the level. + $levels = ['standard' => 'Standard', 'medium' => 'Medium · 3:1', 'high' => 'High · 7:1']; + $levelRoles = ['bg-primary', 'bg-on-primary', 'bg-primary-container', 'bg-on-primary-container', 'bg-on-surface-variant', 'bg-outline', 'bg-error', 'bg-success', 'bg-warning', 'bg-info']; + $profile = \NoNameWeb\LivewireMaterial\Support\Scheme::profile(); + $examples = [ 'Colour profiles' => <<<'BLADE'
BLADE, + 'Contrast level' => <<<'BLADE' + + BLADE, ]; @endphp @@ -34,10 +43,43 @@ profiles the picker draws nothing.

+

+ Every scheme is generated at M3's three contrast levels — standard, medium (3:1) and high (7:1) — and the + head script writes the visitor's to <html data-contrast>, following the operating system + until they choose. The page keeps its surface; the ink, the outlines and the containers carry the level. +

+ @foreach ($examples as $title => $code) @endforeach +
+ @foreach ($levels as $level => $title) + {{-- data-scheme beside data-contrast: a profile's level blocks key on both attributes + together, as they do on . --}} +
+

{{ $title }}

+ + @foreach (['light', 'dark'] as $theme) +
+

{{ $theme }}

+ + @foreach ($levelRoles as $role) +
+ + {{ \Illuminate\Support\Str::after($role, 'bg-') }} +
+ @endforeach +
+ @endforeach +
+ @endforeach +
+
@foreach (['light', 'dark'] as $theme)
diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index f14191c3..db85e50b 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -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('')) + ->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('')) ->toContain('data-theme-toggle="toggle"') diff --git a/tests/Feature/Components/SchemePickerTest.php b/tests/Feature/Components/SchemePickerTest.php index 7b770401..3f548709 100644 --- a/tests/Feature/Components/SchemePickerTest.php +++ b/tests/Feature/Components/SchemePickerTest.php @@ -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(''); - expect($html)->toMatch('/
/') + expect($html)->toMatch('//') ->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(''); + + 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(''); - 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 () {