Keep the theme-color meta in step with the theme

An app installed as a PWA, and a mobile browser, colour their bar from
<meta name="theme-color">, which the package left alone: the bar kept the
server's one colour whatever theme the visitor chose.

With the new `theme.meta` option (off by default), <x-theme-script> sets the
content of every theme-color meta without a `media` attribute to the
resolved theme's surface, from the scheme data and for the profile in
<html data-scheme>, adding one to <head> when the page has none. A
MutationObserver on <html> follows every later change of data-theme or
data-scheme: $store.theme's set() and toggle(), an OS change while
`system`, a profile preview, an application's own script. wire:navigate's
head merge puts the next page's server-rendered meta in place, so it is
painted again as the page is swapped in and on livewire:navigated. Turned
off, the script is byte-for-byte what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 17:59:34 +02:00
co-authored by Claude Opus 5
parent 3a7aa96ec7
commit bcf0d4f4e3
6 changed files with 210 additions and 3 deletions
+1
View File
@@ -86,6 +86,7 @@ php artisan vendor:publish --tag=livewire-material-config
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works. - `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once). - `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles). - `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`. - `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`). - `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
+8
View File
@@ -28,12 +28,20 @@ return [
| localStorage under 'storage_key'; values found under 'legacy_keys' (an | localStorage under 'storage_key'; values found under 'legacy_keys' (an
| earlier theme toggle's key) are adopted once and then removed. | earlier theme toggle's key) are adopted once and then removed.
| |
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
| or a mobile browser gives its bar) on the resolved theme's surface, and
| the active colour profile's: the head script sets it before the first
| paint, adds one when the page has none, and follows every later change,
| wire:navigate included. A theme-color meta with a `media` attribute is
| left alone.
|
*/ */
'theme' => [ 'theme' => [
'default' => 'system', 'default' => 'system',
'storage_key' => 'material-theme', 'storage_key' => 'material-theme',
'legacy_keys' => [], 'legacy_keys' => [],
'meta' => false,
], ],
/* /*
@@ -91,7 +91,9 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
## Theme ## Theme
`config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything. `config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
## Toasts ## Toasts
@@ -187,7 +189,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
### `<x-theme-script>` ### `<x-theme-script>`
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
### `<x-button>` ### `<x-button>`
@@ -21,10 +21,21 @@
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js) rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it. changes it.
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
<meta name="theme-color"> without a `media` attribute one is added to <head> when there is
none is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
<html data-scheme> (else the active one). A MutationObserver on <html> 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
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.
wire:navigate swaps the body, merges the head without running this again, and gives <html> wire:navigate swaps the body, merges the head without running this again, and gives <html>
the next page's attributes which the server rendered without any of these, so Livewire the next page's attributes which the server rendered without any of these, so Livewire
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task, removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
before anything paints), so this only has to run on a full load. --}} before anything paints), so this only has to run on a full load. The head merge also puts the
next page's server-rendered theme-color meta in place of the painted one, so it is painted
again there, and once more on `livewire:navigated`. --}}
@php @php
$theme = config('livewire-material.theme'); $theme = config('livewire-material.theme');
@@ -40,6 +51,17 @@
'key' => $rail['storage_key'] ?? 'material-rail', 'key' => $rail['storage_key'] ?? 'material-rail',
], ],
]; ];
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
if ((bool) ($theme['meta'] ?? false)) {
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
$settings['meta'] = [
...$surfaces($schemeProfiles[$settings['scheme']] ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
];
}
@endphp @endphp
<script> <script>
@@ -94,6 +116,39 @@
apply(); apply();
media.addEventListener('change', apply); media.addEventListener('change', apply);
@if (isset($settings['meta']))
var paintThemeColor = function () {
var theme = root.getAttribute('data-theme');
var scheme = root.getAttribute('data-scheme');
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 metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
if (metas.length === 0) {
var meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
metas = [meta];
}
Array.prototype.forEach.call(metas, function (meta) {
if (meta.getAttribute('content') !== colour) {
meta.setAttribute('content', colour);
}
});
};
paintThemeColor();
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
document.addEventListener('DOMContentLoaded', paintThemeColor);
document.addEventListener('livewire:navigated', paintThemeColor);
@endif
document.addEventListener('livewire:navigating', function (event) { 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-rail', 'data-rail-key'].map(function (name) {
@@ -106,6 +161,10 @@
root.setAttribute(attribute[0], attribute[1]); root.setAttribute(attribute[0], attribute[1]);
} }
}); });
@if (isset($settings['meta']))
paintThemeColor();
@endif
}); });
}); });
})(@json($settings)); })(@json($settings));
+97
View File
@@ -1,5 +1,11 @@
<?php <?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use function Orchestra\Testbench\workbench_path;
/** /**
* The theme <html> shows. A bare `html` selector would be read as text to search for. * The theme <html> shows. A bare `html` selector would be read as text to search for.
*/ */
@@ -56,3 +62,94 @@ it('repaints a section that sets its own theme', function () {
->assertScript(theme('data-theme', 'light')) ->assertScript(theme('data-theme', 'light'))
->assertScript("{$swatch(0)} !== {$swatch(1)}"); ->assertScript("{$swatch(0)} !== {$swatch(1)}");
}); });
/**
* The page has exactly one theme-color meta without a media query, and it shows the given colour.
*/
function themeColorIs(string $hex): string
{
return "(() => { const metas = document.head.querySelectorAll('meta[name=theme-color]:not([media])'); return metas.length === 1 && metas[0].getAttribute('content') === '{$hex}'; })()";
}
/**
* The surface colour role, as <html> resolves it.
*/
function pageSurfaceIs(string $hex): string
{
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface').trim() === '{$hex}'";
}
function themeReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('adds a theme-color meta in the painted surface, and follows the theme and the colour profile', function () {
config([
'livewire-material.theme.meta' => true,
'livewire-material.scheme' => workbench_path('resources/css/material-scheme.json'),
]);
$profiles = Scheme::profiles();
$page = themeReady(visit('/material/colour')->inLightMode())
->assertScript(theme('data-scheme', 'baseline'))
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
$page->click('[data-theme-option="dark"]')
->assertScript(theme('data-theme', 'dark'))
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
$page->click('#colour [data-scheme-option="rose"]')
->assertScript(theme('data-scheme', 'rose'))
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
$page->click('[data-theme-option="light"]')
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
->assertNoJavaScriptErrors();
});
it('repaints the page\'s own theme-color meta, and the next page\'s after wire:navigate', function () {
config(['livewire-material.theme.meta' => true]);
Route::middleware('web')->get('/theme-color-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<meta name="theme-color" content="#000000" />
<meta name="theme-color" media="print" content="#ffffff" />
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<p id="page">This is page {{ $page }}.</p>
<a id="next" href="/theme-color-probe/two" wire:navigate>Next</a>
@livewireScripts
</body>
</html>
BLADE, ['page' => $page]));
$scheme = Scheme::load();
$page = themeReady(visit('/theme-color-probe/one')->inLightMode())
->assertScript(themeColorIs($scheme['light']['surface']))
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'");
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
$page->assertScript(themeColorIs($scheme['dark']['surface']));
$page->click('#next')
->assertSeeIn('#page', 'This is page two.')
->assertScript("window.eval('window.samePage') === true")
->assertScript(theme('data-theme', 'dark'))
->assertScript(themeColorIs($scheme['dark']['surface']))
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'")
->assertNoJavaScriptErrors();
});
@@ -67,3 +67,43 @@ it('names the active colour profile for <html data-scheme>, and none for a singl
File::delete($path); File::delete($path);
} }
}); });
it('leaves the theme-color meta alone by default', function () {
$this->blade('<x-theme-script />')
->assertDontSee('theme-color', false)
->assertDontSee('"meta"', false)
->assertDontSee('MutationObserver', false);
});
it('paints the theme-color meta in the resolved theme\'s surface when asked', function () {
config(['livewire-material.theme.meta' => true]);
$scheme = Scheme::load();
$this->blade('<x-theme-script />')
->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","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("document.addEventListener('livewire:navigated', paintThemeColor);", false);
});
it('gives the theme-color meta every profile\'s surfaces, the active one\'s first', 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']],
'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']],
],
]));
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
Scheme::resolveProfileUsing(fn (): string => 'teal');
try {
$this->blade('<x-theme-script />')
->assertSee('"meta":{"light":"#f4fbf8","dark":"#0e1513","profiles":{"indigo":{"light":"#fbf8ff","dark":"#12131a"},"teal":{"light":"#f4fbf8","dark":"#0e1513"}}}', false);
} finally {
Scheme::resolveProfileUsing(null);
File::delete($path);
}
});