Files
livewire-material/resources/views/components/theme-script.blade.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 7084aac788 Resolve the contrast level and the motion scheme before the first paint
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 <html data-contrast> — nothing for standard,
which is what the stylesheet's plain blocks already are. It writes
<html data-motion="standard"> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:34:29 +02:00

233 lines
11 KiB
PHP

{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
<head>, ahead of @vite.
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
operating system, now and whenever it changes. The result is written to
<html data-theme>, which is the only thing the stylesheet keys on; the choice itself is
<html data-theme-choice>, where `$store.theme` picks it up.
A value under one of `theme.legacy_keys` an earlier toggle's key, which may be
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
<html data-contrast>, 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
<html data-contrast-choice>, where `$store.theme` picks it up.
`livewire-material.motion.scheme` is M3's motion scheme: `standard` is written to
<html data-motion> (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
<html data-scheme>, which the generated stylesheet keys each profile on.
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
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) and at the level in <html data-contrast>. A
MutationObserver on <html> 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.
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
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. 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
$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, 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']];
// 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'] = [
...$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
<script>
(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 {
var storedRail = localStorage.getItem(settings.rail.key);
if (storedRail === 'collapsed' || storedRail === 'expanded') {
rail = storedRail;
}
var storedContrast = localStorage.getItem(settings.contrast.key);
if (validContrast(storedContrast)) {
contrast = storedContrast;
}
var stored = localStorage.getItem(settings.key);
if (valid(stored)) {
choice = stored;
} else {
settings.legacy.forEach(function (key) {
var legacy = (localStorage.getItem(key) || '').replace(/"/g, '');
if (valid(legacy)) {
choice = legacy;
localStorage.setItem(settings.key, legacy);
}
localStorage.removeItem(key);
});
}
} catch (e) {
// Private windows and blocked site data throw on access; the default stands.
}
var apply = function () {
var current = root.getAttribute('data-theme-choice');
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 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) {
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', '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-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)];
});
event.detail.onSwap(function () {
kept.forEach(function (attribute) {
if (attribute[1] !== null) {
root.setAttribute(attribute[0], attribute[1]);
}
});
@if (isset($settings['meta']))
paintThemeColor();
@endif
});
});
})(@json($settings));
</script>