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
+6 -2
View File
@@ -74,11 +74,13 @@ Generate the scheme from a seed colour. It writes `resources/css/material-scheme
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--success`, `--warning` and `--info` seed the state colours, which are built exactly as M3 builds `error`; `--harmonize` pulls them towards the seed. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
Every scheme is written at M3's three contrast levels — standard, medium (3:1) and high (7:1), for light and dark — keyed on `<html data-contrast>`, which the head script sets before the first paint from the visitor's choice or the operating system's. `--contrast` sets the standard level alone (below 0.5); `<x-theme-toggle mode="contrast" />` lets someone choose.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `harmonize`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
### Configuration
@@ -88,7 +90,9 @@ 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.
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
- `theme.contrast.default` (`system`, `standard`, `medium` or `high`) and `theme.contrast.storage_key` — M3's contrast level, resolved before the first paint into `<html data-contrast>` and followed on the operating system while `system`.
- `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`).
- `motion.scheme` — M3's motion scheme: `expressive` (default) or `standard`, the restrained springs, written to `<html data-motion>`.
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
+211 -30
View File
@@ -4,15 +4,23 @@
* Rebuild with `npm run build:scheme` after changing this file or upgrading
* @material/material-color-utilities; the bundle is committed so applications need
* nothing but `node`. (The published library imports without file extensions, which
* plain Node refuses, so it cannot be run unbundled anyway.)
* plain Node refuses, so it cannot be run unbundled anyway — and two of the classes
* below are imported by path, which only a bundler resolves.)
*
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
* Input: one JSON argument — {seed, variant, spec, contrast, harmonize, success, warning,
* info}. `spec` is the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it
* first shipped). `contrast` is the standard level, below M3's medium (0.5).
* Output: JSON on stdout — {seed, variant, spec, harmonize, contrast: {standard, medium:
* {light, dark}, high: {light, dark}}, light: {role: hex}, dark: {role: hex}}. The top-level
* light and dark are the standard level; medium (0.5) and high (1.0) are M3's other two
* contrast levels, generated for both themes so a stylesheet can key them on an attribute.
*/
import {
argbFromHex,
customColor,
Blend,
clampDouble,
DynamicColor,
extendSpecVersion,
hexFromArgb,
Hct,
MaterialDynamicColors,
@@ -25,7 +33,12 @@ import {
SchemeRainbow,
SchemeTonalSpot,
SchemeVibrant,
TonalPalette,
Variant,
} from '@material/material-color-utilities'
// Google exports neither from the package's index; the bundler resolves the path.
import { ContrastCurve } from '../node_modules/@material/material-color-utilities/dynamiccolor/contrast_curve.js'
import { ToneDeltaPair } from '../node_modules/@material/material-color-utilities/dynamiccolor/tone_delta_pair.js'
const VARIANTS = {
'tonal-spot': SchemeTonalSpot,
@@ -39,9 +52,15 @@ const VARIANTS = {
'fruit-salad': SchemeFruitSalad,
}
// M3's three contrast levels (styles/color/roles, "What's new May 2025"): standard is the
// scheme's own level, medium and high are fixed. Every level is generated for both themes.
const LEVELS = { standard: 0, medium: 0.5, high: 1 }
// Key colours seed the palettes; they are not roles a stylesheet paints with.
const NOT_ROLES = /_palette_key_color$/
const STATES = ['success', 'warning', 'info']
function fail(message) {
process.stderr.write(`${message}\n`)
process.exit(1)
@@ -64,22 +83,179 @@ if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${inp
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
for (const state of ['success', 'warning', 'info']) {
for (const state of STATES) {
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
}
const contrast = Number(input.contrast ?? 0)
const harmonize = Boolean(input.harmonize ?? false)
if (!(contrast >= -1 && contrast <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
if (!(contrast >= -1 && contrast < LEVELS.medium)) {
fail(`The standard contrast level runs from -1 to below ${LEVELS.medium}, "${input.contrast}" given; medium and high are generated as their own blocks.`)
}
const source = Hct.fromInt(argbFromHex(input.seed))
const colors = new MaterialDynamicColors()
function roles(isDark) {
/**
* The contrast curves of Google's 2025 spec, from its private getCurve()
* (dynamiccolor/color_spec_2025.js).
*/
function curve(defaultContrast) {
return {
1.5: new ContrastCurve(1.5, 1.5, 3, 5.5),
3: new ContrastCurve(3, 3, 4.5, 7),
4.5: new ContrastCurve(4.5, 4.5, 7, 11),
6: new ContrastCurve(6, 6, 7, 11),
7: new ContrastCurve(7, 7, 11, 21),
}[defaultContrast]
}
/** The tone that holds the palette's chroma, searching up or down — Google's findBestToneForChroma. */
function bestToneForChroma(hue, chroma, tone, byDecreasingTone) {
let answer = tone
let best = Hct.from(hue, chroma, answer)
while (best.chroma < chroma) {
if (tone < 0 || tone > 100) break
tone += byDecreasingTone ? -1 : 1
const candidate = Hct.from(hue, chroma, tone)
if (best.chroma < candidate.chroma) {
best = candidate
answer = tone
}
}
return answer
}
/** Google's tMaxC: the most chromatic tone, from the top. */
function tMaxC(palette, lowerBound = 0, upperBound = 100) {
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 100, true))
}
/** Google's tMinC: the most chromatic tone, from the bottom. */
function tMinC(palette, lowerBound = 0, upperBound = 100) {
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 0, false))
}
/**
* success, warning and info as M3 custom colours: the four roles of a semantic colour, built on
* their own tonal palette exactly as Google builds error / on-error / error-container /
* on-error-container — the same tones, contrast curves and tone delta pairs in both specs
* (dynamiccolor/color_spec_2021.js and color_spec_2025.js, "Errors [E]"). Because they are
* DynamicColors against the page's own DynamicScheme, they follow its contrast level, its dark
* tones, its spec version and its platform the way every other role does; the 2021 recipe they
* used before (customColor) was fixed at tones 40/100/90/10 whatever the scheme asked for.
*/
function stateRoles(name, palette) {
const of = () => palette
let color, onColor, container, onContainer
color = extendSpecVersion(
DynamicColor.fromPalette({
name,
palette: of,
tone: (s) => (s.isDark ? 80 : 40),
isBackground: true,
background: (s) => colors.highestSurface(s),
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 7),
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
}),
'2025',
DynamicColor.fromPalette({
name,
palette: of,
tone: (s) => (s.platform === 'phone' ? (s.isDark ? tMinC(palette, 0, 98) : tMaxC(palette)) : tMinC(palette)),
isBackground: true,
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : colors.surfaceContainerHigh()),
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
toneDeltaPair: (s) => (s.platform === 'phone' ? new ToneDeltaPair(container, color, 5, 'relative_lighter', true, 'farther') : undefined),
}),
)
onColor = extendSpecVersion(
DynamicColor.fromPalette({
name: `on_${name}`,
palette: of,
tone: (s) => (s.isDark ? 20 : 100),
background: () => color,
contrastCurve: () => new ContrastCurve(4.5, 7, 11, 21),
}),
'2025',
DynamicColor.fromPalette({
name: `on_${name}`,
palette: of,
// Google's on-error sits on error-dim off the phone; a state colour has no dim role.
background: () => color,
contrastCurve: (s) => (s.platform === 'phone' ? curve(6) : curve(7)),
}),
)
container = extendSpecVersion(
DynamicColor.fromPalette({
name: `${name}_container`,
palette: of,
tone: (s) => (s.isDark ? 30 : 90),
isBackground: true,
background: (s) => colors.highestSurface(s),
contrastCurve: () => new ContrastCurve(1, 1, 3, 4.5),
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
}),
'2025',
DynamicColor.fromPalette({
name: `${name}_container`,
palette: of,
tone: (s) => (s.platform === 'watch' ? 30 : s.isDark ? tMinC(palette, 30, 93) : tMaxC(palette, 0, 90)),
isBackground: true,
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : undefined),
toneDeltaPair: (s) => (s.platform === 'watch' ? new ToneDeltaPair(container, color, 10, 'darker', true, 'farther') : undefined),
contrastCurve: (s) => (s.platform === 'phone' && s.contrastLevel > 0 ? curve(1.5) : undefined),
}),
)
onContainer = extendSpecVersion(
DynamicColor.fromPalette({
name: `on_${name}_container`,
palette: of,
tone: (s) => (s.variant === Variant.MONOCHROME ? (s.isDark ? 90 : 10) : s.isDark ? 90 : 30),
background: () => container,
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 11),
}),
'2025',
DynamicColor.fromPalette({
name: `on_${name}_container`,
palette: of,
background: () => container,
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
}),
)
return {
[name]: color,
[`on-${name}`]: onColor,
[`${name}-container`]: container,
[`on-${name}-container`]: onContainer,
}
}
// Harmonisation pulls a state's hue towards the seed, so the three read as part of the scheme
// (styles/color/advanced/define-new-colors). Off by default: a state has to stay recognisable
// whatever the brand is, and a green that has turned blue no longer says "success".
const states = Object.assign({}, ...STATES.map((state) => {
const value = argbFromHex(input[state])
return stateRoles(state, TonalPalette.fromInt(harmonize ? Blend.harmonize(value, argbFromHex(input.seed)) : value))
}))
function roles(isDark, contrastLevel) {
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
// M3's original colour, for an application whose palette was generated with it.
const scheme = new Scheme(source, isDark, contrast, spec)
const scheme = new Scheme(source, isDark, contrastLevel, spec)
const out = {}
for (const color of colors.allColors) {
@@ -88,36 +264,41 @@ function roles(isDark) {
}
}
for (const [role, color] of Object.entries(states)) {
out[role] = hexFromArgb(color.getArgb(scheme))
}
return { scheme, out }
}
const light = roles(false)
const dark = roles(true)
/** One contrast level: both themes, with each theme's inverse state colours from the other. */
function level(contrastLevel) {
const light = roles(false, contrastLevel)
const dark = roles(true, contrastLevel)
// success, warning and info are M3 custom colours, harmonisation off: harmonising pulls
// each hue towards the seed, and a state has to stay recognisable whatever the brand is.
for (const state of ['success', 'warning', 'info']) {
const group = customColor(argbFromHex(input.seed), { name: state, value: argbFromHex(input[state]), blend: false })
for (const [theme, colours] of [['light', light.out], ['dark', dark.out]]) {
colours[state] = hexFromArgb(group[theme].color)
colours[`on-${state}`] = hexFromArgb(group[theme].onColor)
colours[`${state}-container`] = hexFromArgb(group[theme].colorContainer)
colours[`on-${state}-container`] = hexFromArgb(group[theme].onColorContainer)
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
for (const state of ['error', ...STATES]) {
light.out[`inverse-${state}`] = dark.out[state]
dark.out[`inverse-${state}`] = light.out[state]
}
return { spec: light.scheme.specVersion, light: light.out, dark: dark.out }
}
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
for (const state of ['error', 'success', 'warning', 'info']) {
light.out[`inverse-${state}`] = dark.out[state]
dark.out[`inverse-${state}`] = light.out[state]
}
const standard = level(contrast)
const medium = level(LEVELS.medium)
const high = level(LEVELS.high)
process.stdout.write(JSON.stringify({
seed: input.seed.toLowerCase(),
variant: input.variant,
spec: light.scheme.specVersion,
contrast,
light: light.out,
dark: dark.out,
spec: standard.spec,
harmonize,
contrast: {
standard: contrast,
medium: { light: medium.light, dark: medium.dark },
high: { light: high.light, dark: high.dark },
},
light: standard.light,
dark: standard.dark,
}))
+27
View File
@@ -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 <html data-contrast> 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
| <html data-motion> before the first paint, and the spatial springs swap.
| The effects springs are the same in both.
|
*/
'motion' => [
'scheme' => 'expressive',
],
/*
@@ -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">
+112 -19
View File
@@ -19,7 +19,8 @@ class SchemeCommand extends Command
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
{--contrast=0 : The contrast level, from -1 to 1}
{--contrast=0 : The standard level\'s contrast, from -1 to below 0.5; medium and high are always generated}
{--harmonize : Pull the success, warning and info colours towards the seed}
{--success=#22a06b : The source of the success colour}
{--warning=#e2a400 : The source of the warning colour}
{--info=#1d7afc : The source of the info colour}
@@ -38,6 +39,16 @@ class SchemeCommand extends Command
*/
protected const SPECS = ['2021', '2025'];
/**
* M3's other two contrast levels, generated for every scheme beside the standard one and
* keyed on <html data-contrast> (styles/color/roles, "What's new May 2025"): medium is
* 3:1 on the roles that carry text, high 7:1. Their levels are Google's, not the
* installation's `--contrast` moves the standard block alone.
*
* @var array<string, float>
*/
protected const LEVELS = ['medium' => 0.5, 'high' => 1.0];
public function handle(Filesystem $files): int
{
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
@@ -51,11 +62,16 @@ class SchemeCommand extends Command
}
if (filled($this->argument('seed'))) {
if ($this->refusesContrast((float) $this->option('contrast'))) {
return self::FAILURE;
}
$input = [
'seed' => (string) $this->argument('seed'),
'variant' => (string) $this->option('variant'),
'spec' => $spec,
'contrast' => (float) $this->option('contrast'),
'harmonize' => (bool) $this->option('harmonize'),
'success' => (string) $this->option('success'),
'warning' => (string) $this->option('warning'),
'info' => (string) $this->option('info'),
@@ -87,12 +103,17 @@ class SchemeCommand extends Command
return self::FAILURE;
}
if ($this->refusesContrast((float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ")) {
return self::FAILURE;
}
// A profile's own spec and state colours win; without them, the command's options apply.
$scheme = $this->generate([
'seed' => (string) ($profile['seed'] ?? ''),
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
'spec' => (string) ($profile['spec'] ?? $spec),
'contrast' => (float) ($profile['contrast'] ?? 0),
'harmonize' => (bool) ($profile['harmonize'] ?? $this->option('harmonize')),
'success' => (string) ($profile['success'] ?? $this->option('success')),
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
'info' => (string) ($profile['info'] ?? $this->option('info')),
@@ -115,11 +136,33 @@ class SchemeCommand extends Command
]);
}
/**
* Whether the given standard contrast level is one this command generates, having said why
* it is not. Medium and high are M3's own levels and always generated; `--contrast` moves
* the standard block, which has to stay below them.
*/
protected function refusesContrast(float $contrast, string $context = ''): bool
{
if ($contrast >= self::LEVELS['medium'] || $contrast < -1) {
$this->components->error($context.sprintf(
'The contrast level %s is the standard block\'s, from -1 to below %s. Medium (%s) and high (%s) are always generated beside it, under [data-contrast]; the head script picks one.',
$contrast,
self::LEVELS['medium'],
self::LEVELS['medium'],
self::LEVELS['high'],
));
return true;
}
return false;
}
/**
* One scheme from Google's colour utilities, or null once the reason has been shown.
*
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
* @return array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array{standard: float, medium: array{light: array<string, string>, dark: array<string, string>}, high: array{light: array<string, string>, dark: array<string, string>}}, light: array<string, string>, dark: array<string, string>}|null
*/
protected function generate(array $input, string $context = ''): ?array
{
@@ -162,8 +205,8 @@ class SchemeCommand extends Command
* The stylesheet, headed by the command that regenerates it: every option that differs from
* its default is written out.
*
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
* @param array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
*/
protected function stylesheet(array $scheme, array $input): string
{
@@ -173,15 +216,16 @@ class SchemeCommand extends Command
->implode('');
$command = sprintf(
'php artisan material:scheme "%s" --variant=%s%s%s%s',
'php artisan material:scheme "%s" --variant=%s%s%s%s%s',
$scheme['seed'],
$scheme['variant'],
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
$scheme['contrast']['standard'] != 0 ? ' --contrast='.$scheme['contrast']['standard'] : '',
$scheme['harmonize'] ? ' --harmonize' : '',
$states,
);
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
$blocks = $this->levels($scheme);
return <<<CSS
/*
@@ -192,6 +236,11 @@ class SchemeCommand extends Command
* 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.
*/
{$blocks}
@@ -205,18 +254,19 @@ class SchemeCommand extends Command
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
* page's profile.
*
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
* @param array<string, array{label: string, seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>}> $profiles
*/
protected function profilesStylesheet(array $profiles, string $default): string
{
$list = collect($profiles)
->map(fn (array $profile, string $name): string => sprintf(
' * %-12s %s, %s%s%s',
' * %-12s %s, %s%s%s%s',
$name,
$profile['seed'],
$profile['variant'],
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
$profile['contrast']['standard'] != 0 ? ', contrast '.$profile['contrast']['standard'] : '',
$profile['harmonize'] ? ', harmonized' : '',
))
->implode("\n");
@@ -232,25 +282,68 @@ class SchemeCommand extends Command
{$list}
*
* 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 and data-scheme
* before the first paint.
* M3's contrast guarantee only as generated. The head script sets data-theme, data-scheme
* and data-contrast before the first paint.
*/
CSS;
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
$css .= "\n".$this->levels($profiles[$default]);
foreach ($profiles as $name => $profile) {
$css .= "\n".$this->blocks(
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
$profile,
);
$css .= "\n".$this->levels($profile, "[data-scheme='{$name}']");
}
return $css;
}
/**
* One scheme, whole: the standard level first, then M3's medium and high under
* data-contrast. A level's blocks are the standard ones with one more attribute, so they
* outrank them wherever both match, and the light blocks of a level come before its dark
* ones as in every other pair here. The nested `[data-contrast='high'] [data-theme='light']`
* form keeps a light panel inside a high-contrast dark page on the level the page asked for
* the plain `[data-theme='light']` block would otherwise take it back to standard.
*
* @param array{contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
*/
protected function levels(array $scheme, string $prefix = ''): string
{
[$light, $dark] = $this->selectors($prefix);
$css = $this->blocks($light, $dark, $scheme);
foreach (array_keys(self::LEVELS) as $level) {
[$light, $dark] = $this->selectors($prefix."[data-contrast='{$level}']");
/** @var array{light: array<string, string>, dark: array<string, string>} $roles */
$roles = $scheme['contrast'][$level];
$css .= "\n".$this->blocks($light, $dark, $roles);
}
return $css;
}
/**
* The light and the dark selectors of one block, under the given prefix. Without a prefix
* the plain blocks stand on `:root` and on the attribute alone; with one, the prefix is on
* <html> and the theme may be on <html> or on a panel inside it, so both forms are written.
*
* @return array{list<string>, list<string>}
*/
protected function selectors(string $prefix): array
{
if ($prefix === '') {
return [[':root', "[data-theme='light']"], ["[data-theme='dark']"]];
}
return [
[$prefix, "{$prefix}[data-theme='light']", "{$prefix} [data-theme='light']"],
["{$prefix}[data-theme='dark']", "{$prefix} [data-theme='dark']"],
];
}
/**
* A light and a dark block of roles under the given selectors.
*
+50 -21
View File
@@ -18,6 +18,11 @@ use Throwable;
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
* refused, so nothing but a colour ever reaches a stylesheet.
*
* The roles at the top level are the standard contrast level; M3's medium and high levels sit
* under `contrast` and are asked for by name (`load($path, $profile, 'high')`). A file written
* before the levels existed or one whose level is missing a role falls back to the standard
* roles, so a mail or an error page never draws a hole.
*
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
* default under `default`, and the default's roles at the top level as a single scheme does. Which
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
@@ -30,6 +35,14 @@ use Throwable;
*/
class Scheme
{
/**
* M3's contrast levels, as `material:scheme` writes them and as <html data-contrast> names
* them. `standard` is the scheme's own level and has no attribute.
*
* @var list<string>
*/
public const LEVELS = ['standard', 'medium', 'high'];
/**
* @var (Closure(): ?string)|null
*/
@@ -46,14 +59,15 @@ class Scheme
}
/**
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
* The roles to draw: the given profile's, the active profile's, or the single scheme's, at
* the given contrast level (`standard`, `medium` or `high`; anything else is standard).
*
* @return array{light: array<string, string>, dark: array<string, string>}
*/
public static function load(?string $path = null, ?string $profile = null): array
public static function load(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
{
$data = static::data($path);
$profiles = static::profilesFrom($data);
$profiles = static::profilesFrom($data, $contrast);
if ($profiles !== []) {
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
@@ -61,13 +75,7 @@ class Scheme
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
}
$default = static::defaultScheme();
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
return [
'light' => [...$default['light'], ...$scheme['light']],
'dark' => [...$default['dark'], ...$scheme['dark']],
];
return static::scheme($data, $contrast);
}
/**
@@ -75,9 +83,9 @@ class Scheme
*
* @return array<string, string>
*/
public static function light(?string $path = null, ?string $profile = null): array
public static function light(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
{
return static::load($path, $profile)['light'];
return static::load($path, $profile, $contrast)['light'];
}
/**
@@ -85,9 +93,9 @@ class Scheme
*
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
public static function profiles(?string $path = null): array
public static function profiles(?string $path = null, string $contrast = 'standard'): array
{
return static::profilesFrom(static::data($path));
return static::profilesFrom(static::data($path), $contrast);
}
/**
@@ -116,13 +124,12 @@ class Scheme
* @param array<mixed> $data
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
protected static function profilesFrom(array $data): array
protected static function profilesFrom(array $data, string $contrast = 'standard'): array
{
if (! is_array($data['profiles'] ?? null)) {
return [];
}
$default = static::defaultScheme();
$profiles = [];
foreach ($data['profiles'] as $name => $profile) {
@@ -132,14 +139,32 @@ class Scheme
$profiles[$name] = [
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
...static::scheme($profile, $contrast),
];
}
return $profiles;
}
/**
* One scheme's roles at one contrast level: the level's own, over the standard ones it does
* not name, over the package's default. A scheme file written before the levels existed has
* only the standard roles, and draws them at every level rather than nothing.
*
* @param array<mixed> $data
* @return array{light: array<string, string>, dark: array<string, string>}
*/
protected static function scheme(array $data, string $contrast): array
{
$default = static::defaultScheme($contrast);
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
return [
'light' => [...$default['light'], ...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
'dark' => [...$default['dark'], ...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
];
}
/**
* @param array<mixed> $data
* @param non-empty-array<string, mixed> $profiles
@@ -162,15 +187,19 @@ class Scheme
}
/**
* The package's own scheme, which fills any role a file lacks.
* The package's own scheme at one contrast level, which fills any role a file lacks.
*
* @return array{light: array<string, string>, dark: array<string, string>}
*/
protected static function defaultScheme(): array
protected static function defaultScheme(string $contrast = 'standard'): array
{
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
return [
'light' => [...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
'dark' => [...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
];
}
/**
+13
View File
@@ -69,6 +69,19 @@ it('draws floating and docked toolbars', function () {
->not->toContain('data-toolbar-group');
});
it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-theme-toggle="contrast"')
->toContain('role="radiogroup"')
->toContain('aria-label="Contrast"')
->toContain('data-contrast-option="standard"')
->toContain('data-contrast-option="medium"')
->toContain('data-contrast-option="high"')
->toContain("\$store.theme.setContrast('high')")
// The row follows the resolved level, so the operating system's shows under `system`.
->toContain("(\$store.theme.resolvedContrast === 'medium').toString()");
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade('<x-theme-toggle />'))
->toContain('data-theme-toggle="toggle"')
+31 -3
View File
@@ -35,11 +35,37 @@ it('draws a radio per generated profile, bound, named and previewing on change',
it('puts its other attributes on the group and the binding on the radios', function () {
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />');
expect($html)->toMatch('/<fieldset x-data data-scheme-picker class="min-w-0 mt-4" data-test="color-profile">/')
expect($html)->toMatch('/<fieldset\s+x-data="\{ swatches: [^"]+\}"\s+data-scheme-picker\s+class="min-w-0 mt-4" data-test="color-profile"\s*>/')
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
});
it('swaps the dots to the contrast level on screen', function () {
File::put($this->path, json_encode([
'default' => 'indigo',
'profiles' => [
'indigo' => [
'label' => 'Indigo',
'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'],
'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7'],
'contrast' => ['high' => ['light' => ['primary' => '#241bb0'], 'dark' => ['primary' => '#eeeaff']]],
],
],
]));
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
expect($html)
// Standard is the inline pair, so a dot is painted before Alpine runs.
->toContain('style="--swatch-light: #4f46e5; --swatch-dark: #c0c1ff"')
->toContain('\u0022standard\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#4f46e5\u0022')
->toContain('\u0022high\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#241bb0\u0022')
// A level the file does not carry repeats the standard colours rather than none.
->toContain('\u0022medium\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#4f46e5\u0022')
->toContain('swatches[\'indigo\'][$store.theme.resolvedContrast].light[\'primary\']')
->toContain('swatches[\'indigo\'][$store.theme.resolvedContrast].dark[\'tertiary\']');
});
it('keeps its inline styles to the scheme file\'s checked colours', function () {
File::put($this->path, json_encode([
'default' => 'indigo',
@@ -48,10 +74,12 @@ it('keeps its inline styles to the scheme file\'s checked colours', function ()
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
preg_match_all('/style="([^"]*)"/', $html, $styles);
preg_match_all('/\sstyle="([^"]*)"/', $html, $styles);
expect($styles[1])->not->toBeEmpty()
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/');
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/')
// The colours Alpine swaps in are the same filtered ones, never the file's own text.
->and($html)->not->toContain('url(');
});
it('shows a validation message for the bound property instead of the hint', function () {
+58 -8
View File
@@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
it('follows the operating system until the visitor chooses, by default', function () {
$this->blade('<x-theme-script />')
->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('<x-theme-script />')
->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('<x-theme-script />')->assertSee('"contrast":{"default":"system"', false);
});
it('writes the standard motion scheme to <html>, and nothing for the expressive one', function () {
$this->blade('<x-theme-script />')
->assertSee('"motion":null', false)
->assertDontSee("root.setAttribute('data-motion', 'standard')", false);
config(['livewire-material.motion.scheme' => 'standard']);
$this->blade('<x-theme-script />')
->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 <html> when wire:navigate swaps the page', function () {
$this->blade('<x-theme-script />')
->assertSee("document.addEventListener('livewire:navigating'", false)
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-auto', '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-auto', '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('<x-theme-script />')
->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 <html> 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('<x-theme-script />'))
->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('<x-theme-script />')
->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);
+17
View File
@@ -86,6 +86,23 @@ function withPackageMailComponents(): void
app()->forgetInstance(Markdown::class);
}
it('stays on the standard contrast level, which is the one a mail client can show', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
'dark' => ['primary' => '#abcdef'],
'contrast' => [
'standard' => 0,
'high' => ['light' => ['primary' => '#000000', 'on-primary' => '#ffffff'], 'dark' => ['primary' => '#ffffff']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))
->toContain('background-color: #123456')
->not->toContain('background-color: #000000');
});
it('inlines the application\'s scheme onto the mail', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
+166 -1
View File
@@ -3,6 +3,37 @@
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* The hue of a #rrggbb colour, in degrees: what harmonisation moves.
*/
function schemeHue(string $hex): float
{
[$r, $g, $b] = array_map(fn (string $channel): float => hexdec($channel) / 255, str_split(ltrim($hex, '#'), 2));
$chroma = max($r, $g, $b) - min($r, $g, $b);
if ($chroma === 0.0) {
return 0.0;
}
$sector = match (max($r, $g, $b)) {
$r => fmod(($g - $b) / $chroma, 6),
$g => (($b - $r) / $chroma) + 2,
default => (($r - $g) / $chroma) + 4,
};
return fmod(($sector * 60) + 360, 360);
}
/**
* How far apart two colours are on the hue circle, the short way round.
*/
function schemeHueDistance(string $one, string $other): float
{
$apart = abs(schemeHue($one) - schemeHue($other));
return min($apart, 360 - $apart);
}
beforeEach(function () {
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
$this->data = Str::replaceLast('.css', '.json', $this->stylesheet);
@@ -23,6 +54,8 @@ it('writes the scheme as a stylesheet and as data', function () {
->seed->toBe('#4f46e5')
->variant->toBe('tonal-spot')
->spec->toBe('2025')
->harmonize->toBeFalse()
->and($scheme['contrast']['standard'])->toBe(0)
->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light']))
->and($scheme['light'])->toHaveKeys(['primary', 'on-primary', 'tertiary-container', 'surface-container-high', 'outline-variant', 'success', 'on-warning-container', 'info-container'])
->and($scheme['light']['surface'])->not->toBe($scheme['dark']['surface'])
@@ -41,6 +74,137 @@ it('writes the scheme as a stylesheet and as data', function () {
expect($stylesheet)->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot');
});
it('writes M3\'s three contrast levels for both themes, keyed on data-contrast', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
// The standard level keeps the 1.x shape at the top; the other two sit under `contrast`.
expect(array_keys($scheme['contrast']))->toBe(['standard', 'medium', 'high'])
->and(array_keys($scheme['contrast']['medium']['light']))->toEqual(array_keys($scheme['light']))
->and(array_keys($scheme['contrast']['high']['dark']))->toEqual(array_keys($scheme['dark']));
foreach (['medium', 'high'] as $level) {
foreach (['light' => "[data-contrast='{$level}'] [data-theme='light'] {", 'dark' => "[data-contrast='{$level}'] [data-theme='dark'] {"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};");
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
expect($block)->toContain("--md-sys-color-{$role}: {$hex};");
}
}
expect($stylesheet)
->toContain("[data-contrast='{$level}'],\n[data-contrast='{$level}'][data-theme='light'],")
->toContain("[data-contrast='{$level}'][data-theme='dark'],");
}
// Every level differs from the one below it, in both themes and for the state colours too.
foreach (['light', 'dark'] as $theme) {
foreach (['on-surface-variant', 'outline', 'success', 'info', 'success-container'] as $role) {
expect($scheme['contrast']['medium'][$theme][$role])
->not->toBe($scheme[$theme][$role], "medium {$theme} {$role}")
->not->toBe($scheme['contrast']['high'][$theme][$role], "high {$theme} {$role}");
}
// The state colours are dynamic colours now, so the level reaches them as it does a
// built-in role. (A container whose tone already clears the level's ratio stays put,
// exactly as error's does — hence the pairs rather than every one of the four.)
foreach (['success', 'warning', 'info'] as $state) {
foreach ([$state, "on-{$state}", "on-{$state}-container"] as $role) {
expect($scheme['contrast']['high'][$theme][$role])->not->toBe($scheme[$theme][$role], "high {$theme} {$role}");
}
}
}
expect($stylesheet)->not->toContain('prefers-contrast');
});
it('puts a level\'s nested light blocks after the dark ones of the level below', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$stylesheet = File::get($this->stylesheet);
$at = fn (string $selector): int => strpos($stylesheet, $selector);
// A light panel inside a dark high-contrast page: its rule carries one attribute more than
// the plain dark block and stands after it, so it wins on specificity and on order.
expect($at("[data-theme='dark'] {"))->toBeLessThan($at("[data-contrast='high'] [data-theme='light'] {"))
->and($at("[data-contrast='medium'] [data-theme='light'] {"))->toBeLessThan($at("[data-contrast='medium'][data-theme='dark'],"))
->and($at("[data-contrast='medium'][data-theme='dark'],"))->toBeLessThan($at("[data-contrast='high'],"));
});
it('generates the levels for every profile, under its own data-scheme', function () {
config(['livewire-material.profiles' => [
'indigo' => ['seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['seed' => '#00897b', 'variant' => 'vibrant'],
]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
foreach ($scheme['profiles'] as $name => $profile) {
foreach (['medium', 'high'] as $level) {
$selector = "[data-scheme='{$name}'][data-contrast='{$level}'] [data-theme='dark'] {";
expect(Str::of($stylesheet)->after($selector)->before('}')->toString())
->toContain("--md-sys-color-primary: {$profile['contrast'][$level]['dark']['primary']};")
->and($profile['contrast'][$level]['light']['success'])->not->toBe($profile['light']['success']);
}
}
// The default profile's plain blocks come first, its levels next, then every profile's.
expect(strpos($stylesheet, "[data-contrast='high'],"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
});
it('refuses a standard contrast level at or above the medium one', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.5', '--output' => $this->stylesheet])
->expectsOutputToContain('Medium (0.5) and high (1) are always generated')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'contrast' => 1]]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "indigo": The contrast level 1')
->assertFailed();
});
it('keeps a standard level below medium, and records it in the header', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.3', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
expect($scheme['contrast']['standard'])->toBe(0.3)
->and($scheme['light']['primary'])->not->toBe($scheme['contrast']['medium']['light']['primary'])
->and(File::get($this->stylesheet))->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot --contrast=0.3');
});
it('harmonises the state colours towards the seed only when asked', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])->assertSuccessful();
$plain = json_decode(File::get($this->data), true);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--harmonize' => true, '--output' => $this->stylesheet])->assertSuccessful();
$harmonized = json_decode(File::get($this->data), true);
expect($harmonized['harmonize'])->toBeTrue()
->and($plain['harmonize'])->toBeFalse()
->and(File::get($this->stylesheet))->toContain('--variant=tonal-spot --harmonize');
foreach (['success', 'warning', 'info'] as $state) {
expect(schemeHueDistance($harmonized['light'][$state], '#4f46e5'))
->toBeLessThan(schemeHueDistance($plain['light'][$state], '#4f46e5'), "{$state} moved towards the seed")
->and($harmonized['contrast']['high']['dark'][$state])->not->toBe($plain['contrast']['high']['dark'][$state]);
}
});
it('generates a different scheme per variant', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
->assertSuccessful();
@@ -177,7 +341,8 @@ it('takes a profile\'s own spec and state colours, and the command\'s for a prof
->and($profiles['expressive'])->spec->toBe('2025')
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
// The state colours follow the spec now, so the same source is not the same colour.
->and($profiles['expressive']['dark']['info'])->not->toBe('#80cfff')
->and(File::get($this->stylesheet))
->toContain('(spec 2025)')
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
+28
View File
@@ -19,6 +19,11 @@ function writeProfiles(string $path, array $primaries, string $default): void
'contrast' => 0,
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
'contrast' => [
'standard' => 0,
'medium' => ['light' => ['primary' => '#332f8a'], 'dark' => ['surface' => '#0a0a0a']],
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff', 'surface' => '#000000']],
],
])->all();
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
@@ -69,6 +74,29 @@ it('loads a profile by name, whatever is active', function () {
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
});
it('loads a contrast level, falling back to the standard roles it does not name', function () {
expect(Scheme::load($this->path, 'teal', 'high')['light']['primary'])->toBe('#000000')
->and(Scheme::load($this->path, 'teal', 'high')['dark']['surface'])->toBe('#000000')
// The level names no surface in light, so the standard one stands.
->and(Scheme::light($this->path, 'teal', 'high')['surface'])->toBe('#fafafa')
->and(Scheme::load($this->path, 'teal', 'medium')['light']['primary'])->toBe('#332f8a')
->and(Scheme::load($this->path, 'teal')['light']['primary'])->toBe('#00897b')
// A level nobody generated, and a name that is not one, are the standard scheme.
->and(Scheme::load($this->path, 'teal', 'extreme')['light']['primary'])->toBe('#00897b')
->and(Scheme::profiles($this->path, 'high')['rose']['light']['primary'])->toBe('#000000')
->and(Scheme::profiles($this->path)['rose']['light']['primary'])->toBe('#c2185b');
});
it('draws a high contrast level from the package default for a scheme file without one', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
$package = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
expect(Scheme::light($this->path, null, 'high')['primary'])->toBe('#123456')
->and(Scheme::light($this->path, null, 'high')['surface'])->toBe($package['contrast']['high']['light']['surface'])
->and(Scheme::light($this->path)['surface'])->toBe($package['light']['surface']);
});
it('has no profile for a single scheme, and loads it as before', function () {
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
Scheme::resolveProfileUsing(fn (): string => 'teal');
+21 -2
View File
@@ -66,6 +66,23 @@ it('ships a default scheme that declares every role in both themes', function ()
}
});
it('ships M3\'s medium and high contrast levels beside the standard one', function () {
$scheme = json_decode(File::get(packageCss('tokens/scheme.json')), true);
$css = File::get(packageCss('tokens/scheme.css'));
foreach (['medium', 'high'] as $level) {
foreach (['light', 'dark'] as $theme) {
$declared = declarations($css, "[data-contrast='{$level}'] [data-theme='{$theme}'] {");
expect(array_keys($declared))->toEqual(array_map(fn (string $role): string => "--md-sys-color-{$role}", array_keys($scheme['light'])));
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
expect($declared["--md-sys-color-{$role}"])->toBe($hex);
}
}
}
});
it('turns every role into a Tailwind colour', function () {
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
$theme = File::get(packageCss('tokens/theme.css'));
@@ -86,9 +103,11 @@ it('resolves colour utilities on the element, so a nested data-theme repaints th
->not->toMatch('/--[\w-]+:\s*#/');
});
it('leaves the choice of theme to the head script, never to a media query', function () {
it('leaves the choice of theme and of contrast to the head script, never to a media query', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())->not->toContain('prefers-color-scheme');
expect($file->getContents())
->not->toContain('prefers-color-scheme')
->not->toContain('prefers-contrast');
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff