From 7084aac78881110913ee23c6954225553975e779 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:34:29 +0200 Subject: [PATCH] Resolve the contrast level and the motion scheme before the first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The head script reads livewire-material.theme.contrast (standard, medium, high or system, kept under its own storage key), asks the operating system while the choice is system and follows it, and writes — nothing for standard, which is what the stylesheet's plain blocks already are. It writes for motion.scheme, and both attributes, with the new choice and key ones, survive wire:navigate. $store.theme gains contrast, resolvedContrast and setContrast(); the theme-color meta takes its surface from the resolved level. Plan: docs/plans/material-3-alignment.md, steps 5 and 7 (core C2, C9). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- config/livewire-material.php | 27 ++++++ resources/js/theme.js | 41 ++++++++++ .../views/components/theme-script.blade.php | 82 ++++++++++++++++--- tests/Feature/Components/ThemeScriptTest.php | 66 +++++++++++++-- 4 files changed, 197 insertions(+), 19 deletions(-) diff --git a/config/livewire-material.php b/config/livewire-material.php index 3f88ca67..29bbdab1 100644 --- a/config/livewire-material.php +++ b/config/livewire-material.php @@ -35,6 +35,12 @@ return [ | wire:navigate included. A theme-color meta with a `media` attribute is | left alone. | + | 'contrast' is M3's contrast level, the same three the scheme is generated + | in: 'standard', 'medium' (3:1) or 'high' (7:1), or 'system' to follow the + | operating system's own contrast setting until the visitor chooses. The + | head script writes it to before the first paint — + | standard, having the plain blocks, writes no attribute. + | */ 'theme' => [ @@ -42,6 +48,27 @@ return [ 'storage_key' => 'material-theme', 'legacy_keys' => [], 'meta' => false, + 'contrast' => [ + 'default' => 'system', + 'storage_key' => 'material-contrast', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Motion + |-------------------------------------------------------------------------- + | + | M3's two motion schemes. 'expressive' (the default) is the bouncy one the + | library draws with; 'standard' is the restrained set, "minimal bounce, + | for utilitarian products" — the head script writes it to + | before the first paint, and the spatial springs swap. + | The effects springs are the same in both. + | + */ + + 'motion' => [ + 'scheme' => 'expressive', ], /* diff --git a/resources/js/theme.js b/resources/js/theme.js index f81dadb8..e7c82d44 100644 --- a/resources/js/theme.js +++ b/resources/js/theme.js @@ -8,6 +8,11 @@ * `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes * through the same write as `set()` and `toggle()`: the attributes, then localStorage. * + * M3's contrast level is the same pair: `contrast` is the choice (standard, medium, high or + * system) and `resolvedContrast` the level on screen, which is `standard` wherever + * carries no data-contrast — the stylesheet's plain blocks are that level. `setContrast()` + * writes both, through the same attribute the head script wrote before the first paint. + * * `scheme` is the colour profile on screen (, which the server chose), and * `previewScheme(name)` shows another one on this page without storing anything — the * application saves a choice itself, and the next full load draws what it saved. @@ -15,13 +20,18 @@ document.addEventListener('alpine:init', () => { const root = document.documentElement const media = window.matchMedia('(prefers-color-scheme: dark)') + const contrastMedia = window.matchMedia('(prefers-contrast: more)') const choices = ['light', 'dark', 'system'] + const levels = ['standard', 'medium', 'high', 'system'] const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice) + const resolveContrast = (choice) => (choice === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : choice) window.Alpine.store('theme', { choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system', resolved: root.dataset.theme === 'dark' ? 'dark' : 'light', + contrast: levels.includes(root.dataset.contrastChoice) ? root.dataset.contrastChoice : 'system', + resolvedContrast: root.dataset.contrast === 'medium' || root.dataset.contrast === 'high' ? root.dataset.contrast : 'standard', scheme: root.dataset.scheme || null, get value() { @@ -54,6 +64,29 @@ document.addEventListener('alpine:init', () => { this.set(this.resolved === 'dark' ? 'light' : 'dark') }, + setContrast(choice) { + if (!levels.includes(choice)) { + return + } + + this.contrast = choice + this.resolvedContrast = resolveContrast(choice) + + root.dataset.contrastChoice = choice + + if (this.resolvedContrast === 'standard') { + delete root.dataset.contrast + } else { + root.dataset.contrast = this.resolvedContrast + } + + try { + localStorage.setItem(root.dataset.contrastKey || 'material-contrast', choice) + } catch { + // Blocked storage: the page still switches, it just will not remember. + } + }, + previewScheme(name) { if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) { return @@ -73,4 +106,12 @@ document.addEventListener('alpine:init', () => { theme.resolved = resolve('system') } }) + + contrastMedia.addEventListener('change', () => { + const theme = window.Alpine.store('theme') + + if (theme.contrast === 'system') { + theme.resolvedContrast = resolveContrast('system') + } + }) }) diff --git a/resources/views/components/theme-script.blade.php b/resources/views/components/theme-script.blade.php index 41fa9ecc..a145a120 100644 --- a/resources/views/components/theme-script.blade.php +++ b/resources/views/components/theme-script.blade.php @@ -11,6 +11,17 @@ JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a visitor who never chose, so changing `theme.default` later reaches them too. + M3's contrast level rides the same rails (`livewire-material.theme.contrast`): + `standard`, `medium`, `high` or `system`, kept under `contrast.storage_key`. `system` asks + the operating system, now and whenever it changes, and takes `more` as high. The result is + , which the generated stylesheet keys its medium and high blocks on — + standard writes no attribute, because the plain blocks are already it. The choice is + , where `$store.theme` picks it up. + + `livewire-material.motion.scheme` is M3's motion scheme: `standard` is written to + (and swaps the spatial springs for the restrained set), `expressive`, + the default, writes nothing. + With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the active one — `Scheme::profile()`, which asks the application's resolver — is written to , which the generated stylesheet keys each profile on. @@ -24,9 +35,10 @@ With `theme.meta` on, the browser's own chrome follows too: the `content` of every without a `media` attribute — one is added to when there is none — is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in - (else the active one). A MutationObserver on keeps it in step with - whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`, - an OS change while `system`, a profile preview, the application's own script. A layout's own + (else the active one) and at the level in . A + MutationObserver on keeps it in step with whatever changes `data-theme`, + `data-scheme` or `data-contrast` afterwards: `$store.theme.set()` and `toggle()`, + `setContrast()`, an OS change while `system`, a profile preview, the application's own script. A layout's own theme-color meta belongs before this script: one written after it is only painted on DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted. @@ -40,27 +52,44 @@ @php $theme = config('livewire-material.theme'); $rail = config('livewire-material.rail'); + $contrast = $theme['contrast'] ?? []; + $levels = \NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS; $settings = [ 'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(), 'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system', 'key' => $theme['storage_key'] ?? 'material-theme', 'legacy' => array_values($theme['legacy_keys'] ?? []), + 'contrast' => [ + 'default' => in_array($contrast['default'] ?? null, [...$levels, 'system'], true) ? $contrast['default'] : 'system', + 'key' => $contrast['storage_key'] ?? 'material-contrast', + ], + 'motion' => (config('livewire-material.motion.scheme') === 'standard') ? 'standard' : null, 'rail' => [ 'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded', 'key' => $rail['storage_key'] ?? 'material-rail', ], ]; - // Only the surfaces the meta can show: the active scheme's, and every profile's for a preview. + // Only the surfaces the meta can show: the active scheme's, every profile's for a preview, + // and each of those at M3's other two contrast levels. `Scheme::load()` resolves the active + // profile itself, so it answers for a single scheme and for profiles alike. if ((bool) ($theme['meta'] ?? false)) { $surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']]; - $schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(); + + // One read of the scheme file per level rather than one per profile and level. + $profilesAt = collect($levels)->mapWithKeys(fn (string $level): array => [$level => \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(contrast: $level)]); + $otherLevels = fn (callable $of): object => (object) collect($levels)->skip(1)->mapWithKeys(fn (string $level): array => [$level => $of($level)])->all(); $settings['meta'] = [ - // PHP 8.5 deprecates a null array offset: without profiles the scheme is null. - ...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()), - 'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(), + ...$surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load()), + 'contrast' => $otherLevels(fn (string $level): array => $surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load(contrast: $level))), + 'profiles' => (object) collect($profilesAt['standard']) + ->map(fn (array $scheme, string $name): array => [ + ...$surfaces($scheme), + 'contrast' => $otherLevels(fn (string $level): array => $surfaces($profilesAt[$level][$name])), + ]) + ->all(), ]; } @endphp @@ -69,8 +98,11 @@ (function (settings) { var root = document.documentElement; var media = window.matchMedia('(prefers-color-scheme: dark)'); + var contrastMedia = window.matchMedia('(prefers-contrast: more)'); var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; }; + var validContrast = function (value) { return value === 'standard' || value === 'medium' || value === 'high' || value === 'system'; }; var choice = settings.default; + var contrast = settings.contrast.default; var rail = settings.rail.default; try { @@ -80,6 +112,12 @@ rail = storedRail; } + var storedContrast = localStorage.getItem(settings.contrast.key); + + if (validContrast(storedContrast)) { + contrast = storedContrast; + } + var stored = localStorage.getItem(settings.key); if (valid(stored)) { @@ -106,28 +144,50 @@ root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current); }; + // Standard is the stylesheet's plain blocks, so it is the absence of the attribute. + var applyContrast = function () { + var current = root.getAttribute('data-contrast-choice'); + var level = current === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : current; + + if (level === 'medium' || level === 'high') { + root.setAttribute('data-contrast', level); + } else { + root.removeAttribute('data-contrast'); + } + }; + if (settings.scheme) { root.setAttribute('data-scheme', settings.scheme); } + if (settings.motion) { + root.setAttribute('data-motion', settings.motion); + } + root.setAttribute('data-theme-key', settings.key); root.setAttribute('data-theme-choice', choice); + root.setAttribute('data-contrast-key', settings.contrast.key); + root.setAttribute('data-contrast-choice', contrast); root.setAttribute('data-rail-key', settings.rail.key); root.setAttribute('data-rail', rail); apply(); + applyContrast(); media.addEventListener('change', apply); + contrastMedia.addEventListener('change', applyContrast); @if (isset($settings['meta'])) var paintThemeColor = function () { var theme = root.getAttribute('data-theme'); var scheme = root.getAttribute('data-scheme'); + var level = root.getAttribute('data-contrast'); if ((theme !== 'light' && theme !== 'dark') || !document.head) { return; } - var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme]; + var surfaces = Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta; + var colour = (Object.prototype.hasOwnProperty.call(surfaces.contrast, level) ? surfaces.contrast[level] : surfaces)[theme]; var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])'); if (metas.length === 0) { @@ -146,13 +206,13 @@ }; paintThemeColor(); - new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] }); + new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme', 'data-contrast'] }); document.addEventListener('DOMContentLoaded', paintThemeColor); document.addEventListener('livewire:navigated', paintThemeColor); @endif document.addEventListener('livewire:navigating', function (event) { - var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) { + var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', 'data-rail-key'].map(function (name) { return [name, root.getAttribute(name)]; }); diff --git a/tests/Feature/Components/ThemeScriptTest.php b/tests/Feature/Components/ThemeScriptTest.php index a982fc31..bff50a34 100644 --- a/tests/Feature/Components/ThemeScriptTest.php +++ b/tests/Feature/Components/ThemeScriptTest.php @@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme; it('follows the operating system until the visitor chooses, by default', function () { $this->blade('') - ->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('') + ->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('')->assertSee('"contrast":{"default":"system"', false); +}); + +it('writes the standard motion scheme to , and nothing for the expressive one', function () { + $this->blade('') + ->assertSee('"motion":null', false) + ->assertDontSee("root.setAttribute('data-motion', 'standard')", false); + + config(['livewire-material.motion.scheme' => 'standard']); + + $this->blade('') + ->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 when wire:navigate swaps the page', function () { $this->blade('') ->assertSee("document.addEventListener('livewire:navigating'", false) - ->assertSee("['data-scheme', '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-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', '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('') - ->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 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('')) + ->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('') - ->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);