From 57d9e0aa6fd6340efe0dc70c3142dbc777619286 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:38:31 +0200 Subject: [PATCH] Offer the contrast level where the theme is chosen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is the picker's row for M3's three levels, marking the one in force so the operating system's setting shows while the choice is system. The scheme picker's dots follow the level on screen instead of always promising the standard colours, and the showcase's colour section puts the three levels side by side — data-scheme beside data-contrast, the way a profile's level blocks key. Plan: docs/plans/material-3-alignment.md, step 7 (core C2). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../views/components/scheme-picker.blade.php | 37 ++++++++++++++-- .../views/components/theme-toggle.blade.php | 35 +++++++++++++++- .../views/showcase/sections/colour.blade.php | 42 +++++++++++++++++++ tests/Feature/Components/AppBarTest.php | 13 ++++++ tests/Feature/Components/SchemePickerTest.php | 34 +++++++++++++-- 5 files changed, 152 insertions(+), 9 deletions(-) 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 () {