Merge branch 'worktree-agent-a9537eabbf38e0608'

# Conflicts:
#	resources/views/components/scheme-picker.blade.php
#	resources/views/components/theme-script.blade.php
#	resources/views/showcase/sections/colour.blade.php
#	tests/Feature/Components/ThemeScriptTest.php
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:43:37 +02:00
23 changed files with 4086 additions and 408 deletions
@@ -44,7 +44,18 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--harmonize` pulls those three towards the seed (off by default: a state has to stay recognisable). The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
`success`, `warning` and `info` are built exactly as M3 builds `error` — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (`bg-success`, `text-on-warning-container`), never as a hex.
### Contrast levels
Every scheme is generated at M3's three levels: standard, medium (3:1) and high (7:1), for light and dark and for every profile. `--contrast` (and a profile's `contrast`) moves the **standard** level only and must stay below 0.5; medium and high are Google's fixed levels and are always written, under `[data-contrast='medium']` and `[data-contrast='high']`.
- `<html data-contrast>` is the level on screen, written by `<x-theme-script>` before the first paint; standard writes no attribute, because the plain blocks are already standard. No stylesheet ever asks `prefers-contrast` — the head script does, once.
- `theme.contrast.default` is `system` (follow the operating system), `standard`, `medium` or `high`, kept in localStorage under `theme.contrast.storage_key`.
- In Alpine: `$store.theme.contrast` (the choice), `$store.theme.resolvedContrast` (the level showing) and `$store.theme.setContrast('high')`. `<x-theme-toggle mode="contrast">` is the ready-made row of three.
- Nothing in a template names a level: a role's value changes underneath it. Never set `data-contrast` on an element to make a corner of the page higher-contrast — the level is the visitor's, page-wide.
### Colour profiles
@@ -62,7 +73,7 @@ An installation that switches between several schemes lists them in `config/live
php artisan material:scheme
```
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0, below 0.5), `harmonize`, `spec`, `success`, `warning`, `info` (for these five, without the key the command's `--harmonize`, `--spec`, `--success`, `--warning`, `--info` or their defaults apply). Every profile is generated at all three contrast levels, keyed on `[data-scheme='x'][data-contrast='high']` and so on.
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
@@ -72,7 +83,7 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
```
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`. Both take a contrast level as their last argument (`Scheme::load($path, $profile, 'high')`, `Scheme::profiles(contrast: 'medium')`); without one they answer with the standard level, which is what a mail wears.
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
## Tokens
@@ -92,9 +103,9 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
## Theme
`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.
`config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`, `theme.contrast` (`default` and `storage_key`, see Contrast levels) and `motion.scheme` (`expressive`, the default, or `standard` — M3's restrained springs, written to `<html data-motion>`). 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. It also holds `contrast`, `resolvedContrast` and `setContrast()` for the contrast level. With colour profiles it 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.
`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>`, at the level in `<html data-contrast>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme`, `data-scheme` or `data-contrast` (`$store.theme.set()`/`toggle()`, `setContrast()`, 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.
## Safe areas
@@ -808,11 +819,11 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
### `<x-theme-toggle>`
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store.
### `<x-scheme-picker>`
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour, at the contrast level on screen — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
```blade
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
+320 -29
View File
@@ -6,6 +6,11 @@
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme before the
* first paint; the light block also stands without it.
*
* M3's medium and high contrast levels follow the standard blocks, keyed on
* data-contrast — which the head script writes from the visitor's choice, or from the
* operating system's contrast setting while that choice is `system`. No media query
* decides here, as none decides the theme.
*/
:root,
@@ -61,22 +66,22 @@
--md-sys-color-on-error: #fff7f7;
--md-sys-color-error-container: #f97386;
--md-sys-color-on-error-container: #6e0523;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success: #006d46;
--md-sys-color-on-success: #e7ffed;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-on-success-container: #00734a;
--md-sys-color-warning: #7c5900;
--md-sys-color-on-warning: #fff8f1;
--md-sys-color-warning-container: #fab925;
--md-sys-color-on-warning-container: #6a4b00;
--md-sys-color-info: #005bc5;
--md-sys-color-on-info: #f9f8ff;
--md-sys-color-info-container: #1c7afc;
--md-sys-color-on-info-container: #001435;
--md-sys-color-inverse-error: #f97386;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
--md-sys-color-inverse-success: #3bb27b;
--md-sys-color-inverse-warning: #f3b31e;
--md-sys-color-inverse-info: #699cff;
}
[data-theme='dark'] {
@@ -131,20 +136,306 @@
--md-sys-color-on-error: #490013;
--md-sys-color-error-container: #871c34;
--md-sys-color-on-error-container: #ff97a3;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-success: #3bb27b;
--md-sys-color-on-success: #002615;
--md-sys-color-success-container: #059460;
--md-sys-color-on-success-container: #001e10;
--md-sys-color-warning: #f3b31e;
--md-sys-color-on-warning: #4e3600;
--md-sys-color-warning-container: #e4a604;
--md-sys-color-on-warning-container: #593f00;
--md-sys-color-info: #699cff;
--md-sys-color-on-info: #001e4b;
--md-sys-color-info-container: #0c74f6;
--md-sys-color-on-info-container: #000a23;
--md-sys-color-inverse-error: #a8364b;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
--md-sys-color-inverse-success: #006d46;
--md-sys-color-inverse-warning: #7c5900;
--md-sys-color-inverse-info: #005bc5;
}
[data-contrast='medium'],
[data-contrast='medium'][data-theme='light'],
[data-contrast='medium'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fdf7fe;
--md-sys-color-on-background: #25232b;
--md-sys-color-surface: #fdf7fe;
--md-sys-color-surface-dim: #ded8e4;
--md-sys-color-surface-bright: #fdf7fe;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f8f1fa;
--md-sys-color-surface-container: #f2ecf5;
--md-sys-color-surface-container-high: #ece6f0;
--md-sys-color-surface-container-highest: #e7e0ec;
--md-sys-color-on-surface: #25232b;
--md-sys-color-on-surface-variant: #45414b;
--md-sys-color-outline: #615d68;
--md-sys-color-outline-variant: #7d7983;
--md-sys-color-inverse-surface: #0f0d12;
--md-sys-color-inverse-on-surface: #c8c3c9;
--md-sys-color-primary: #483b6b;
--md-sys-color-primary-dim: #3c305f;
--md-sys-color-on-primary: #e1d3ff;
--md-sys-color-primary-container: #7b6da0;
--md-sys-color-on-primary-container: #ffffff;
--md-sys-color-primary-fixed: #7b6da0;
--md-sys-color-primary-fixed-dim: #6e6093;
--md-sys-color-on-primary-fixed: #ffffff;
--md-sys-color-on-primary-fixed-variant: #ffffff;
--md-sys-color-inverse-primary: #d4c3fd;
--md-sys-color-secondary: #464054;
--md-sys-color-secondary-dim: #3a3548;
--md-sys-color-on-secondary: #dfd5ef;
--md-sys-color-secondary-container: #787187;
--md-sys-color-on-secondary-container: #ffffff;
--md-sys-color-secondary-fixed: #787187;
--md-sys-color-secondary-fixed-dim: #6c657b;
--md-sys-color-on-secondary-fixed: #ffffff;
--md-sys-color-on-secondary-fixed-variant: #ffffff;
--md-sys-color-tertiary: #5c3653;
--md-sys-color-tertiary-dim: #502b47;
--md-sys-color-on-tertiary: #ffcaee;
--md-sys-color-tertiary-container: #936787;
--md-sys-color-on-tertiary-container: #ffffff;
--md-sys-color-tertiary-fixed: #936787;
--md-sys-color-tertiary-fixed-dim: #855b7a;
--md-sys-color-on-tertiary-fixed: #ffffff;
--md-sys-color-on-tertiary-fixed-variant: #ffffff;
--md-sys-color-error: #821830;
--md-sys-color-error-dim: #6b0221;
--md-sys-color-on-error: #ffcdd1;
--md-sys-color-error-container: #c44b5f;
--md-sys-color-on-error-container: #ffffff;
--md-sys-color-success: #004d30;
--md-sys-color-on-success: #7df0b3;
--md-sys-color-success-container: #008656;
--md-sys-color-on-success-container: #ffffff;
--md-sys-color-warning: #593e00;
--md-sys-color-on-warning: #ffd385;
--md-sys-color-warning-container: #986d00;
--md-sys-color-on-warning-container: #ffffff;
--md-sys-color-info: #003f8e;
--md-sys-color-on-info: #cbd9ff;
--md-sys-color-info-container: #006fef;
--md-sys-color-on-info-container: #ffffff;
--md-sys-color-inverse-error: #ff9da8;
--md-sys-color-inverse-success: #5ace94;
--md-sys-color-inverse-warning: #f3b31e;
--md-sys-color-inverse-info: #98b8ff;
}
[data-contrast='medium'][data-theme='dark'],
[data-contrast='medium'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #0f0d12;
--md-sys-color-on-background: #ffffff;
--md-sys-color-surface: #0f0d12;
--md-sys-color-surface-dim: #0f0d12;
--md-sys-color-surface-bright: #2e2b34;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #141218;
--md-sys-color-surface-container: #1b181f;
--md-sys-color-surface-container-high: #211e26;
--md-sys-color-surface-container-highest: #27242d;
--md-sys-color-on-surface: #ffffff;
--md-sys-color-on-surface-variant: #bcb6c2;
--md-sys-color-outline: #96919c;
--md-sys-color-outline-variant: #78737e;
--md-sys-color-inverse-surface: #fdf7fe;
--md-sys-color-inverse-on-surface: #39373c;
--md-sys-color-primary: #cdc0ec;
--md-sys-color-primary-dim: #bfb2de;
--md-sys-color-on-primary: #3a3054;
--md-sys-color-primary-container: #7a6f96;
--md-sys-color-on-primary-container: #ffffff;
--md-sys-color-primary-fixed: #ded0fe;
--md-sys-color-primary-fixed-dim: #d0c3ef;
--md-sys-color-on-primary-fixed: #180e30;
--md-sys-color-on-primary-fixed-variant: #3c3256;
--md-sys-color-inverse-primary: #5a4f75;
--md-sys-color-secondary: #cbc2db;
--md-sys-color-secondary-dim: #beb5cd;
--md-sys-color-on-secondary: #393347;
--md-sys-color-secondary-container: #787187;
--md-sys-color-on-secondary-container: #ffffff;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #221d2f;
--md-sys-color-on-secondary-fixed-variant: #423c50;
--md-sys-color-tertiary: #ffcfef;
--md-sys-color-tertiary-dim: #f4bfe3;
--md-sys-color-on-tertiary: #5e3855;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #542f4c;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #180015;
--md-sys-color-on-tertiary-fixed-variant: #4a2642;
--md-sys-color-error: #ff9da8;
--md-sys-color-error-dim: #ff8695;
--md-sys-color-on-error: #5f001c;
--md-sys-color-error-container: #c44b5f;
--md-sys-color-on-error-container: #ffffff;
--md-sys-color-success: #5ace94;
--md-sys-color-on-success: #00341e;
--md-sys-color-success-container: #008656;
--md-sys-color-on-success-container: #ffffff;
--md-sys-color-warning: #f3b31e;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #e4a604;
--md-sys-color-on-warning-container: #332200;
--md-sys-color-info: #98b8ff;
--md-sys-color-on-info: #002a62;
--md-sys-color-info-container: #006fef;
--md-sys-color-on-info-container: #ffffff;
--md-sys-color-inverse-error: #821830;
--md-sys-color-inverse-success: #004d30;
--md-sys-color-inverse-warning: #593e00;
--md-sys-color-inverse-info: #003f8e;
}
[data-contrast='high'],
[data-contrast='high'][data-theme='light'],
[data-contrast='high'] [data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #fdf7fe;
--md-sys-color-on-background: #000000;
--md-sys-color-surface: #fdf7fe;
--md-sys-color-surface-dim: #ded8e4;
--md-sys-color-surface-bright: #fdf7fe;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f8f1fa;
--md-sys-color-surface-container: #f2ecf5;
--md-sys-color-surface-container-high: #ece6f0;
--md-sys-color-surface-container-highest: #e7e0ec;
--md-sys-color-on-surface: #000000;
--md-sys-color-on-surface-variant: #25232b;
--md-sys-color-outline: #45414b;
--md-sys-color-outline-variant: #54505a;
--md-sys-color-inverse-surface: #0f0d12;
--md-sys-color-inverse-on-surface: #ffffff;
--md-sys-color-primary: #281b49;
--md-sys-color-primary-dim: #1e103f;
--md-sys-color-on-primary: #e1d3ff;
--md-sys-color-primary-container: #584a7b;
--md-sys-color-on-primary-container: #ffffff;
--md-sys-color-primary-fixed: #584a7b;
--md-sys-color-primary-fixed-dim: #4c3f6f;
--md-sys-color-on-primary-fixed: #ffffff;
--md-sys-color-on-primary-fixed-variant: #ffffff;
--md-sys-color-inverse-primary: #d4c3fd;
--md-sys-color-secondary: #262134;
--md-sys-color-secondary-dim: #1c1729;
--md-sys-color-on-secondary: #dfd6ef;
--md-sys-color-secondary-container: #554f64;
--md-sys-color-on-secondary-container: #ffffff;
--md-sys-color-secondary-fixed: #554f64;
--md-sys-color-secondary-fixed-dim: #494358;
--md-sys-color-on-secondary-fixed: #ffffff;
--md-sys-color-on-secondary-fixed-variant: #ffffff;
--md-sys-color-tertiary: #391733;
--md-sys-color-tertiary-dim: #2e0d28;
--md-sys-color-on-tertiary: #ffcbee;
--md-sys-color-tertiary-container: #6d4563;
--md-sys-color-on-tertiary-container: #ffffff;
--md-sys-color-tertiary-fixed: #6d4563;
--md-sys-color-tertiary-fixed-dim: #603a57;
--md-sys-color-on-tertiary-fixed: #ffffff;
--md-sys-color-on-tertiary-fixed-variant: #ffffff;
--md-sys-color-error: #500016;
--md-sys-color-error-dim: #3d000f;
--md-sys-color-on-error: #ffced2;
--md-sys-color-error-container: #97283e;
--md-sys-color-on-error-container: #ffffff;
--md-sys-color-success: #002a18;
--md-sys-color-on-success: #7df0b4;
--md-sys-color-success-container: #005f3c;
--md-sys-color-on-success-container: #ffffff;
--md-sys-color-warning: #312100;
--md-sys-color-on-warning: #ffd486;
--md-sys-color-warning-container: #6c4d00;
--md-sys-color-on-warning-container: #ffffff;
--md-sys-color-info: #002252;
--md-sys-color-on-info: #ccdaff;
--md-sys-color-info-container: #004eac;
--md-sys-color-on-info-container: #ffffff;
--md-sys-color-inverse-error: #ffdddf;
--md-sys-color-inverse-success: #89fcbf;
--md-sys-color-inverse-warning: #ffe2b1;
--md-sys-color-inverse-info: #dde5ff;
}
[data-contrast='high'][data-theme='dark'],
[data-contrast='high'] [data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #0f0d12;
--md-sys-color-on-background: #ffffff;
--md-sys-color-surface: #0f0d12;
--md-sys-color-surface-dim: #0f0d12;
--md-sys-color-surface-bright: #2e2b34;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #141218;
--md-sys-color-surface-container: #1b181f;
--md-sys-color-surface-container-high: #211e26;
--md-sys-color-surface-container-highest: #27242d;
--md-sys-color-on-surface: #ffffff;
--md-sys-color-on-surface-variant: #eae3ef;
--md-sys-color-outline: #bcb6c2;
--md-sys-color-outline-variant: #a7a1ad;
--md-sys-color-inverse-surface: #fdf7fe;
--md-sys-color-inverse-on-surface: #000000;
--md-sys-color-primary: #ebe1ff;
--md-sys-color-primary-dim: #ded0fe;
--md-sys-color-on-primary: #302649;
--md-sys-color-primary-container: #aa9dc8;
--md-sys-color-on-primary-container: #000000;
--md-sys-color-primary-fixed: #ded0fe;
--md-sys-color-primary-fixed-dim: #d0c3ef;
--md-sys-color-on-primary-fixed: #000000;
--md-sys-color-on-primary-fixed-variant: #180e30;
--md-sys-color-inverse-primary: #3c3256;
--md-sys-color-secondary: #ebe1fb;
--md-sys-color-secondary-dim: #ddd3ed;
--md-sys-color-on-secondary: #2e293c;
--md-sys-color-secondary-container: #a8a0b8;
--md-sys-color-on-secondary-container: #000000;
--md-sys-color-secondary-fixed: #e8def8;
--md-sys-color-secondary-fixed-dim: #dad0ea;
--md-sys-color-on-secondary-fixed: #000000;
--md-sys-color-on-secondary-fixed-variant: #221d2f;
--md-sys-color-tertiary: #ffdbf2;
--md-sys-color-tertiary-dim: #fac5e9;
--md-sys-color-on-tertiary: #43203b;
--md-sys-color-tertiary-container: #f4bfe3;
--md-sys-color-on-tertiary-container: #2e0e29;
--md-sys-color-tertiary-fixed: #f4bfe3;
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
--md-sys-color-on-tertiary-fixed: #000000;
--md-sys-color-on-tertiary-fixed-variant: #180015;
--md-sys-color-error: #ffdddf;
--md-sys-color-error-dim: #ffc7cb;
--md-sys-color-on-error: #5f001c;
--md-sys-color-error-container: #ff798c;
--md-sys-color-on-error-container: #000000;
--md-sys-color-success: #89fcbf;
--md-sys-color-on-success: #00341e;
--md-sys-color-success-container: #42b880;
--md-sys-color-on-success-container: #000000;
--md-sys-color-warning: #ffe2b1;
--md-sys-color-on-warning: #3c2900;
--md-sys-color-warning-container: #e4a604;
--md-sys-color-on-warning-container: #000000;
--md-sys-color-info: #dde5ff;
--md-sys-color-on-info: #002a62;
--md-sys-color-info-container: #74a2ff;
--md-sys-color-on-info-container: #000000;
--md-sys-color-inverse-error: #500016;
--md-sys-color-inverse-success: #002a18;
--md-sys-color-inverse-warning: #312100;
--md-sys-color-inverse-info: #002252;
}
+305 -30
View File
@@ -2,7 +2,282 @@
"seed": "#6750a4",
"variant": "tonal-spot",
"spec": "2025",
"contrast": 0,
"harmonize": false,
"contrast": {
"standard": 0,
"medium": {
"light": {
"background": "#fdf7fe",
"on-background": "#25232b",
"surface": "#fdf7fe",
"surface-dim": "#ded8e4",
"surface-bright": "#fdf7fe",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f8f1fa",
"surface-container": "#f2ecf5",
"surface-container-high": "#ece6f0",
"surface-container-highest": "#e7e0ec",
"on-surface": "#25232b",
"on-surface-variant": "#45414b",
"outline": "#615d68",
"outline-variant": "#7d7983",
"inverse-surface": "#0f0d12",
"inverse-on-surface": "#c8c3c9",
"primary": "#483b6b",
"primary-dim": "#3c305f",
"on-primary": "#e1d3ff",
"primary-container": "#7b6da0",
"on-primary-container": "#ffffff",
"primary-fixed": "#7b6da0",
"primary-fixed-dim": "#6e6093",
"on-primary-fixed": "#ffffff",
"on-primary-fixed-variant": "#ffffff",
"inverse-primary": "#d4c3fd",
"secondary": "#464054",
"secondary-dim": "#3a3548",
"on-secondary": "#dfd5ef",
"secondary-container": "#787187",
"on-secondary-container": "#ffffff",
"secondary-fixed": "#787187",
"secondary-fixed-dim": "#6c657b",
"on-secondary-fixed": "#ffffff",
"on-secondary-fixed-variant": "#ffffff",
"tertiary": "#5c3653",
"tertiary-dim": "#502b47",
"on-tertiary": "#ffcaee",
"tertiary-container": "#936787",
"on-tertiary-container": "#ffffff",
"tertiary-fixed": "#936787",
"tertiary-fixed-dim": "#855b7a",
"on-tertiary-fixed": "#ffffff",
"on-tertiary-fixed-variant": "#ffffff",
"error": "#821830",
"error-dim": "#6b0221",
"on-error": "#ffcdd1",
"error-container": "#c44b5f",
"on-error-container": "#ffffff",
"success": "#004d30",
"on-success": "#7df0b3",
"success-container": "#008656",
"on-success-container": "#ffffff",
"warning": "#593e00",
"on-warning": "#ffd385",
"warning-container": "#986d00",
"on-warning-container": "#ffffff",
"info": "#003f8e",
"on-info": "#cbd9ff",
"info-container": "#006fef",
"on-info-container": "#ffffff",
"inverse-error": "#ff9da8",
"inverse-success": "#5ace94",
"inverse-warning": "#f3b31e",
"inverse-info": "#98b8ff"
},
"dark": {
"background": "#0f0d12",
"on-background": "#ffffff",
"surface": "#0f0d12",
"surface-dim": "#0f0d12",
"surface-bright": "#2e2b34",
"surface-container-lowest": "#000000",
"surface-container-low": "#141218",
"surface-container": "#1b181f",
"surface-container-high": "#211e26",
"surface-container-highest": "#27242d",
"on-surface": "#ffffff",
"on-surface-variant": "#bcb6c2",
"outline": "#96919c",
"outline-variant": "#78737e",
"inverse-surface": "#fdf7fe",
"inverse-on-surface": "#39373c",
"primary": "#cdc0ec",
"primary-dim": "#bfb2de",
"on-primary": "#3a3054",
"primary-container": "#7a6f96",
"on-primary-container": "#ffffff",
"primary-fixed": "#ded0fe",
"primary-fixed-dim": "#d0c3ef",
"on-primary-fixed": "#180e30",
"on-primary-fixed-variant": "#3c3256",
"inverse-primary": "#5a4f75",
"secondary": "#cbc2db",
"secondary-dim": "#beb5cd",
"on-secondary": "#393347",
"secondary-container": "#787187",
"on-secondary-container": "#ffffff",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#221d2f",
"on-secondary-fixed-variant": "#423c50",
"tertiary": "#ffcfef",
"tertiary-dim": "#f4bfe3",
"on-tertiary": "#5e3855",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#542f4c",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#180015",
"on-tertiary-fixed-variant": "#4a2642",
"error": "#ff9da8",
"error-dim": "#ff8695",
"on-error": "#5f001c",
"error-container": "#c44b5f",
"on-error-container": "#ffffff",
"success": "#5ace94",
"on-success": "#00341e",
"success-container": "#008656",
"on-success-container": "#ffffff",
"warning": "#f3b31e",
"on-warning": "#412d00",
"warning-container": "#e4a604",
"on-warning-container": "#332200",
"info": "#98b8ff",
"on-info": "#002a62",
"info-container": "#006fef",
"on-info-container": "#ffffff",
"inverse-error": "#821830",
"inverse-success": "#004d30",
"inverse-warning": "#593e00",
"inverse-info": "#003f8e"
}
},
"high": {
"light": {
"background": "#fdf7fe",
"on-background": "#000000",
"surface": "#fdf7fe",
"surface-dim": "#ded8e4",
"surface-bright": "#fdf7fe",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f8f1fa",
"surface-container": "#f2ecf5",
"surface-container-high": "#ece6f0",
"surface-container-highest": "#e7e0ec",
"on-surface": "#000000",
"on-surface-variant": "#25232b",
"outline": "#45414b",
"outline-variant": "#54505a",
"inverse-surface": "#0f0d12",
"inverse-on-surface": "#ffffff",
"primary": "#281b49",
"primary-dim": "#1e103f",
"on-primary": "#e1d3ff",
"primary-container": "#584a7b",
"on-primary-container": "#ffffff",
"primary-fixed": "#584a7b",
"primary-fixed-dim": "#4c3f6f",
"on-primary-fixed": "#ffffff",
"on-primary-fixed-variant": "#ffffff",
"inverse-primary": "#d4c3fd",
"secondary": "#262134",
"secondary-dim": "#1c1729",
"on-secondary": "#dfd6ef",
"secondary-container": "#554f64",
"on-secondary-container": "#ffffff",
"secondary-fixed": "#554f64",
"secondary-fixed-dim": "#494358",
"on-secondary-fixed": "#ffffff",
"on-secondary-fixed-variant": "#ffffff",
"tertiary": "#391733",
"tertiary-dim": "#2e0d28",
"on-tertiary": "#ffcbee",
"tertiary-container": "#6d4563",
"on-tertiary-container": "#ffffff",
"tertiary-fixed": "#6d4563",
"tertiary-fixed-dim": "#603a57",
"on-tertiary-fixed": "#ffffff",
"on-tertiary-fixed-variant": "#ffffff",
"error": "#500016",
"error-dim": "#3d000f",
"on-error": "#ffced2",
"error-container": "#97283e",
"on-error-container": "#ffffff",
"success": "#002a18",
"on-success": "#7df0b4",
"success-container": "#005f3c",
"on-success-container": "#ffffff",
"warning": "#312100",
"on-warning": "#ffd486",
"warning-container": "#6c4d00",
"on-warning-container": "#ffffff",
"info": "#002252",
"on-info": "#ccdaff",
"info-container": "#004eac",
"on-info-container": "#ffffff",
"inverse-error": "#ffdddf",
"inverse-success": "#89fcbf",
"inverse-warning": "#ffe2b1",
"inverse-info": "#dde5ff"
},
"dark": {
"background": "#0f0d12",
"on-background": "#ffffff",
"surface": "#0f0d12",
"surface-dim": "#0f0d12",
"surface-bright": "#2e2b34",
"surface-container-lowest": "#000000",
"surface-container-low": "#141218",
"surface-container": "#1b181f",
"surface-container-high": "#211e26",
"surface-container-highest": "#27242d",
"on-surface": "#ffffff",
"on-surface-variant": "#eae3ef",
"outline": "#bcb6c2",
"outline-variant": "#a7a1ad",
"inverse-surface": "#fdf7fe",
"inverse-on-surface": "#000000",
"primary": "#ebe1ff",
"primary-dim": "#ded0fe",
"on-primary": "#302649",
"primary-container": "#aa9dc8",
"on-primary-container": "#000000",
"primary-fixed": "#ded0fe",
"primary-fixed-dim": "#d0c3ef",
"on-primary-fixed": "#000000",
"on-primary-fixed-variant": "#180e30",
"inverse-primary": "#3c3256",
"secondary": "#ebe1fb",
"secondary-dim": "#ddd3ed",
"on-secondary": "#2e293c",
"secondary-container": "#a8a0b8",
"on-secondary-container": "#000000",
"secondary-fixed": "#e8def8",
"secondary-fixed-dim": "#dad0ea",
"on-secondary-fixed": "#000000",
"on-secondary-fixed-variant": "#221d2f",
"tertiary": "#ffdbf2",
"tertiary-dim": "#fac5e9",
"on-tertiary": "#43203b",
"tertiary-container": "#f4bfe3",
"on-tertiary-container": "#2e0e29",
"tertiary-fixed": "#f4bfe3",
"tertiary-fixed-dim": "#e5b2d5",
"on-tertiary-fixed": "#000000",
"on-tertiary-fixed-variant": "#180015",
"error": "#ffdddf",
"error-dim": "#ffc7cb",
"on-error": "#5f001c",
"error-container": "#ff798c",
"on-error-container": "#000000",
"success": "#89fcbf",
"on-success": "#00341e",
"success-container": "#42b880",
"on-success-container": "#000000",
"warning": "#ffe2b1",
"on-warning": "#3c2900",
"warning-container": "#e4a604",
"on-warning-container": "#000000",
"info": "#dde5ff",
"on-info": "#002a62",
"info-container": "#74a2ff",
"on-info-container": "#000000",
"inverse-error": "#500016",
"inverse-success": "#002a18",
"inverse-warning": "#312100",
"inverse-info": "#002252"
}
}
},
"light": {
"background": "#fdf7fe",
"on-background": "#34313a",
@@ -53,22 +328,22 @@
"on-error": "#fff7f7",
"error-container": "#f97386",
"on-error-container": "#6e0523",
"success": "#006c45",
"on-success": "#ffffff",
"success": "#006d46",
"on-success": "#e7ffed",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"on-success-container": "#00734a",
"warning": "#7c5900",
"on-warning": "#fff8f1",
"warning-container": "#fab925",
"on-warning-container": "#6a4b00",
"info": "#005bc5",
"on-info": "#f9f8ff",
"info-container": "#1c7afc",
"on-info-container": "#001435",
"inverse-error": "#f97386",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
"inverse-success": "#3bb27b",
"inverse-warning": "#f3b31e",
"inverse-info": "#699cff"
},
"dark": {
"background": "#0f0d12",
@@ -120,21 +395,21 @@
"on-error": "#490013",
"error-container": "#871c34",
"on-error-container": "#ff97a3",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"success": "#3bb27b",
"on-success": "#002615",
"success-container": "#059460",
"on-success-container": "#001e10",
"warning": "#f3b31e",
"on-warning": "#4e3600",
"warning-container": "#e4a604",
"on-warning-container": "#593f00",
"info": "#699cff",
"on-info": "#001e4b",
"info-container": "#0c74f6",
"on-info-container": "#000a23",
"inverse-error": "#a8364b",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
"inverse-success": "#006d46",
"inverse-warning": "#7c5900",
"inverse-info": "#005bc5"
}
}
+41
View File
@@ -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 <html>
* 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 (<html data-scheme>, 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')
}
})
})
File diff suppressed because one or more lines are too long
@@ -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 ($profiles === [] ? [] : \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 !== [])
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
<fieldset
x-data="{ swatches: @js($swatches) }"
data-scheme-picker
{{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}
>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif
@@ -50,9 +75,13 @@
/>
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
@foreach (['primary', 'secondary', 'tertiary'] as $role)
@foreach ($roles as $role)
<span
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
x-bind:style="{
'--swatch-light': swatches['{{ $profile }}'][$store.theme.resolvedContrast].light['{{ $role }}'],
'--swatch-dark': swatches['{{ $profile }}'][$store.theme.resolvedContrast].dark['{{ $role }}'],
}"
class="size-5 rounded-corner-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
></span>
@endforeach
@@ -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
<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.
@@ -27,9 +38,10 @@
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
<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.
@@ -43,27 +55,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
@@ -72,8 +101,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;
var railChosen = false;
@@ -85,6 +117,12 @@
railChosen = true;
}
var storedContrast = localStorage.getItem(settings.contrast.key);
if (validContrast(storedContrast)) {
contrast = storedContrast;
}
var stored = localStorage.getItem(settings.key);
if (valid(stored)) {
@@ -111,12 +149,30 @@
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);
@@ -126,19 +182,23 @@
root.setAttribute('data-rail-auto', '');
}
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) {
@@ -157,13 +217,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-auto', '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-auto', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)];
});
@@ -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')
<div
role="radiogroup"
aria-label="{{ __('Contrast') }}"
x-data
data-theme-toggle="contrast"
{{ $attributes->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])
<button
type="button"
role="radio"
aria-checked="false"
x-bind:aria-checked="($store.theme.resolvedContrast === '{{ $level }}').toString()"
x-bind:tabindex="$store.theme.resolvedContrast === '{{ $level }}' ? 0 : -1"
x-on:click="$store.theme.setContrast('{{ $level }}')"
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
data-contrast-option="{{ $level }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
</div>
@elseif ($mode === 'picker')
<div
role="radiogroup"
aria-label="{{ __('Theme') }}"
@@ -11,12 +11,21 @@
'Ink and lines' => ['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'
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
</div>
BLADE,
'Contrast level' => <<<'BLADE'
<x-theme-toggle mode="contrast" />
BLADE,
];
@endphp
@@ -34,10 +43,43 @@
profiles the picker draws nothing.
</p>
<p class="max-w-3xl type-body-md text-on-surface-variant">
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 <code>&lt;html data-contrast&gt;</code>, following the operating system
until they choose. The page keeps its surface; the ink, the outlines and the containers carry the level.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
<div class="grid gap-4 large:grid-cols-3">
@foreach ($levels as $level => $title)
{{-- data-scheme beside data-contrast: a profile's level blocks key on both attributes
together, as they do on <html>. --}}
<div
@if ($profile !== null) data-scheme="{{ $profile }}" @endif
@if ($level !== 'standard') data-contrast="{{ $level }}" @endif
class="space-y-3"
>
<h3 class="type-title-sm text-on-surface-variant">{{ $title }}</h3>
@foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-2 rounded-corner-lg bg-surface p-3 text-on-surface">
<h4 class="type-label-md capitalize text-on-surface-variant">{{ $theme }}</h4>
@foreach ($levelRoles as $role)
<div class="flex items-center gap-2">
<span @class(['size-6 shrink-0 rounded-corner-xs border border-outline-variant', $role])></span>
<span class="min-w-0 truncate type-body-sm">{{ \Illuminate\Support\Str::after($role, 'bg-') }}</span>
</div>
@endforeach
</div>
@endforeach
</div>
@endforeach
</div>
<div class="grid gap-4 expanded:grid-cols-2">
@foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">