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:
@@ -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
|
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
|
#### 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
|
### 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.
|
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
|
||||||
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
|
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
|
||||||
|
- `theme.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`).
|
- `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).
|
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
||||||
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||||
|
|||||||
+211
-30
@@ -4,15 +4,23 @@
|
|||||||
* Rebuild with `npm run build:scheme` after changing this file or upgrading
|
* Rebuild with `npm run build:scheme` after changing this file or upgrading
|
||||||
* @material/material-color-utilities; the bundle is committed so applications need
|
* @material/material-color-utilities; the bundle is committed so applications need
|
||||||
* nothing but `node`. (The published library imports without file extensions, which
|
* 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
|
* Input: one JSON argument — {seed, variant, spec, contrast, harmonize, success, warning,
|
||||||
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
|
* info}. `spec` is the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it
|
||||||
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
* 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 {
|
import {
|
||||||
argbFromHex,
|
argbFromHex,
|
||||||
customColor,
|
Blend,
|
||||||
|
clampDouble,
|
||||||
|
DynamicColor,
|
||||||
|
extendSpecVersion,
|
||||||
hexFromArgb,
|
hexFromArgb,
|
||||||
Hct,
|
Hct,
|
||||||
MaterialDynamicColors,
|
MaterialDynamicColors,
|
||||||
@@ -25,7 +33,12 @@ import {
|
|||||||
SchemeRainbow,
|
SchemeRainbow,
|
||||||
SchemeTonalSpot,
|
SchemeTonalSpot,
|
||||||
SchemeVibrant,
|
SchemeVibrant,
|
||||||
|
TonalPalette,
|
||||||
|
Variant,
|
||||||
} from '@material/material-color-utilities'
|
} 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 = {
|
const VARIANTS = {
|
||||||
'tonal-spot': SchemeTonalSpot,
|
'tonal-spot': SchemeTonalSpot,
|
||||||
@@ -39,9 +52,15 @@ const VARIANTS = {
|
|||||||
'fruit-salad': SchemeFruitSalad,
|
'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.
|
// Key colours seed the palettes; they are not roles a stylesheet paints with.
|
||||||
const NOT_ROLES = /_palette_key_color$/
|
const NOT_ROLES = /_palette_key_color$/
|
||||||
|
|
||||||
|
const STATES = ['success', 'warning', 'info']
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
process.stderr.write(`${message}\n`)
|
process.stderr.write(`${message}\n`)
|
||||||
process.exit(1)
|
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 (!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(', ')}.`)
|
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.`)
|
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const contrast = Number(input.contrast ?? 0)
|
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 source = Hct.fromInt(argbFromHex(input.seed))
|
||||||
const colors = new MaterialDynamicColors()
|
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
|
// 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
|
// 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.
|
// 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 = {}
|
const out = {}
|
||||||
|
|
||||||
for (const color of colors.allColors) {
|
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 }
|
return { scheme, out }
|
||||||
}
|
}
|
||||||
|
|
||||||
const light = roles(false)
|
/** One contrast level: both themes, with each theme's inverse state colours from the other. */
|
||||||
const dark = roles(true)
|
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
|
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
|
||||||
// each hue towards the seed, and a state has to stay recognisable whatever the brand is.
|
for (const state of ['error', ...STATES]) {
|
||||||
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', 'success', 'warning', 'info']) {
|
|
||||||
light.out[`inverse-${state}`] = dark.out[state]
|
light.out[`inverse-${state}`] = dark.out[state]
|
||||||
dark.out[`inverse-${state}`] = light.out[state]
|
dark.out[`inverse-${state}`] = light.out[state]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { spec: light.scheme.specVersion, light: light.out, dark: dark.out }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const standard = level(contrast)
|
||||||
|
const medium = level(LEVELS.medium)
|
||||||
|
const high = level(LEVELS.high)
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
seed: input.seed.toLowerCase(),
|
seed: input.seed.toLowerCase(),
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
spec: light.scheme.specVersion,
|
spec: standard.spec,
|
||||||
contrast,
|
harmonize,
|
||||||
light: light.out,
|
contrast: {
|
||||||
dark: dark.out,
|
standard: contrast,
|
||||||
|
medium: { light: medium.light, dark: medium.dark },
|
||||||
|
high: { light: high.light, dark: high.dark },
|
||||||
|
},
|
||||||
|
light: standard.light,
|
||||||
|
dark: standard.dark,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ return [
|
|||||||
| wire:navigate included. A theme-color meta with a `media` attribute is
|
| wire:navigate included. A theme-color meta with a `media` attribute is
|
||||||
| left alone.
|
| 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' => [
|
'theme' => [
|
||||||
@@ -42,6 +48,27 @@ return [
|
|||||||
'storage_key' => 'material-theme',
|
'storage_key' => 'material-theme',
|
||||||
'legacy_keys' => [],
|
'legacy_keys' => [],
|
||||||
'meta' => false,
|
'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
|
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
|
### Colour profiles
|
||||||
|
|
||||||
@@ -62,7 +73,7 @@ An installation that switches between several schemes lists them in `config/live
|
|||||||
php artisan material:scheme
|
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.
|
- 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:
|
- 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'));
|
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>`.
|
- 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
|
## Tokens
|
||||||
@@ -92,9 +103,9 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
|||||||
|
|
||||||
## Theme
|
## Theme
|
||||||
|
|
||||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `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
|
## Safe areas
|
||||||
|
|
||||||
@@ -808,11 +819,11 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
|||||||
|
|
||||||
### `<x-theme-toggle>`
|
### `<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>`
|
### `<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
|
```blade
|
||||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||||
|
|||||||
+320
-29
@@ -6,6 +6,11 @@
|
|||||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
* 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
|
* M3's contrast guarantee only as generated. The head script sets data-theme before the
|
||||||
* first paint; the light block also stands without it.
|
* 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,
|
:root,
|
||||||
@@ -61,22 +66,22 @@
|
|||||||
--md-sys-color-on-error: #fff7f7;
|
--md-sys-color-on-error: #fff7f7;
|
||||||
--md-sys-color-error-container: #f97386;
|
--md-sys-color-error-container: #f97386;
|
||||||
--md-sys-color-on-error-container: #6e0523;
|
--md-sys-color-on-error-container: #6e0523;
|
||||||
--md-sys-color-success: #006c45;
|
--md-sys-color-success: #006d46;
|
||||||
--md-sys-color-on-success: #ffffff;
|
--md-sys-color-on-success: #e7ffed;
|
||||||
--md-sys-color-success-container: #86f9bc;
|
--md-sys-color-success-container: #86f9bc;
|
||||||
--md-sys-color-on-success-container: #002112;
|
--md-sys-color-on-success-container: #00734a;
|
||||||
--md-sys-color-warning: #7c5800;
|
--md-sys-color-warning: #7c5900;
|
||||||
--md-sys-color-on-warning: #ffffff;
|
--md-sys-color-on-warning: #fff8f1;
|
||||||
--md-sys-color-warning-container: #ffdea6;
|
--md-sys-color-warning-container: #fab925;
|
||||||
--md-sys-color-on-warning-container: #271900;
|
--md-sys-color-on-warning-container: #6a4b00;
|
||||||
--md-sys-color-info: #005ac4;
|
--md-sys-color-info: #005bc5;
|
||||||
--md-sys-color-on-info: #ffffff;
|
--md-sys-color-on-info: #f9f8ff;
|
||||||
--md-sys-color-info-container: #d8e2ff;
|
--md-sys-color-info-container: #1c7afc;
|
||||||
--md-sys-color-on-info-container: #001a42;
|
--md-sys-color-on-info-container: #001435;
|
||||||
--md-sys-color-inverse-error: #f97386;
|
--md-sys-color-inverse-error: #f97386;
|
||||||
--md-sys-color-inverse-success: #69dca1;
|
--md-sys-color-inverse-success: #3bb27b;
|
||||||
--md-sys-color-inverse-warning: #fdbb28;
|
--md-sys-color-inverse-warning: #f3b31e;
|
||||||
--md-sys-color-inverse-info: #aec6ff;
|
--md-sys-color-inverse-info: #699cff;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
@@ -131,20 +136,306 @@
|
|||||||
--md-sys-color-on-error: #490013;
|
--md-sys-color-on-error: #490013;
|
||||||
--md-sys-color-error-container: #871c34;
|
--md-sys-color-error-container: #871c34;
|
||||||
--md-sys-color-on-error-container: #ff97a3;
|
--md-sys-color-on-error-container: #ff97a3;
|
||||||
--md-sys-color-success: #69dca1;
|
--md-sys-color-success: #3bb27b;
|
||||||
--md-sys-color-on-success: #003822;
|
--md-sys-color-on-success: #002615;
|
||||||
--md-sys-color-success-container: #005233;
|
--md-sys-color-success-container: #059460;
|
||||||
--md-sys-color-on-success-container: #86f9bc;
|
--md-sys-color-on-success-container: #001e10;
|
||||||
--md-sys-color-warning: #fdbb28;
|
--md-sys-color-warning: #f3b31e;
|
||||||
--md-sys-color-on-warning: #412d00;
|
--md-sys-color-on-warning: #4e3600;
|
||||||
--md-sys-color-warning-container: #5e4200;
|
--md-sys-color-warning-container: #e4a604;
|
||||||
--md-sys-color-on-warning-container: #ffdea6;
|
--md-sys-color-on-warning-container: #593f00;
|
||||||
--md-sys-color-info: #aec6ff;
|
--md-sys-color-info: #699cff;
|
||||||
--md-sys-color-on-info: #002e6a;
|
--md-sys-color-on-info: #001e4b;
|
||||||
--md-sys-color-info-container: #004396;
|
--md-sys-color-info-container: #0c74f6;
|
||||||
--md-sys-color-on-info-container: #d8e2ff;
|
--md-sys-color-on-info-container: #000a23;
|
||||||
--md-sys-color-inverse-error: #a8364b;
|
--md-sys-color-inverse-error: #a8364b;
|
||||||
--md-sys-color-inverse-success: #006c45;
|
--md-sys-color-inverse-success: #006d46;
|
||||||
--md-sys-color-inverse-warning: #7c5800;
|
--md-sys-color-inverse-warning: #7c5900;
|
||||||
--md-sys-color-inverse-info: #005ac4;
|
--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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,282 @@
|
|||||||
"seed": "#6750a4",
|
"seed": "#6750a4",
|
||||||
"variant": "tonal-spot",
|
"variant": "tonal-spot",
|
||||||
"spec": "2025",
|
"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": {
|
"light": {
|
||||||
"background": "#fdf7fe",
|
"background": "#fdf7fe",
|
||||||
"on-background": "#34313a",
|
"on-background": "#34313a",
|
||||||
@@ -53,22 +328,22 @@
|
|||||||
"on-error": "#fff7f7",
|
"on-error": "#fff7f7",
|
||||||
"error-container": "#f97386",
|
"error-container": "#f97386",
|
||||||
"on-error-container": "#6e0523",
|
"on-error-container": "#6e0523",
|
||||||
"success": "#006c45",
|
"success": "#006d46",
|
||||||
"on-success": "#ffffff",
|
"on-success": "#e7ffed",
|
||||||
"success-container": "#86f9bc",
|
"success-container": "#86f9bc",
|
||||||
"on-success-container": "#002112",
|
"on-success-container": "#00734a",
|
||||||
"warning": "#7c5800",
|
"warning": "#7c5900",
|
||||||
"on-warning": "#ffffff",
|
"on-warning": "#fff8f1",
|
||||||
"warning-container": "#ffdea6",
|
"warning-container": "#fab925",
|
||||||
"on-warning-container": "#271900",
|
"on-warning-container": "#6a4b00",
|
||||||
"info": "#005ac4",
|
"info": "#005bc5",
|
||||||
"on-info": "#ffffff",
|
"on-info": "#f9f8ff",
|
||||||
"info-container": "#d8e2ff",
|
"info-container": "#1c7afc",
|
||||||
"on-info-container": "#001a42",
|
"on-info-container": "#001435",
|
||||||
"inverse-error": "#f97386",
|
"inverse-error": "#f97386",
|
||||||
"inverse-success": "#69dca1",
|
"inverse-success": "#3bb27b",
|
||||||
"inverse-warning": "#fdbb28",
|
"inverse-warning": "#f3b31e",
|
||||||
"inverse-info": "#aec6ff"
|
"inverse-info": "#699cff"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#0f0d12",
|
"background": "#0f0d12",
|
||||||
@@ -120,21 +395,21 @@
|
|||||||
"on-error": "#490013",
|
"on-error": "#490013",
|
||||||
"error-container": "#871c34",
|
"error-container": "#871c34",
|
||||||
"on-error-container": "#ff97a3",
|
"on-error-container": "#ff97a3",
|
||||||
"success": "#69dca1",
|
"success": "#3bb27b",
|
||||||
"on-success": "#003822",
|
"on-success": "#002615",
|
||||||
"success-container": "#005233",
|
"success-container": "#059460",
|
||||||
"on-success-container": "#86f9bc",
|
"on-success-container": "#001e10",
|
||||||
"warning": "#fdbb28",
|
"warning": "#f3b31e",
|
||||||
"on-warning": "#412d00",
|
"on-warning": "#4e3600",
|
||||||
"warning-container": "#5e4200",
|
"warning-container": "#e4a604",
|
||||||
"on-warning-container": "#ffdea6",
|
"on-warning-container": "#593f00",
|
||||||
"info": "#aec6ff",
|
"info": "#699cff",
|
||||||
"on-info": "#002e6a",
|
"on-info": "#001e4b",
|
||||||
"info-container": "#004396",
|
"info-container": "#0c74f6",
|
||||||
"on-info-container": "#d8e2ff",
|
"on-info-container": "#000a23",
|
||||||
"inverse-error": "#a8364b",
|
"inverse-error": "#a8364b",
|
||||||
"inverse-success": "#006c45",
|
"inverse-success": "#006d46",
|
||||||
"inverse-warning": "#7c5800",
|
"inverse-warning": "#7c5900",
|
||||||
"inverse-info": "#005ac4"
|
"inverse-info": "#005bc5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
|
* `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.
|
* 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
|
* `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
|
* `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.
|
* application saves a choice itself, and the next full load draws what it saved.
|
||||||
@@ -15,13 +20,18 @@
|
|||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const contrastMedia = window.matchMedia('(prefers-contrast: more)')
|
||||||
const choices = ['light', 'dark', 'system']
|
const choices = ['light', 'dark', 'system']
|
||||||
|
const levels = ['standard', 'medium', 'high', 'system']
|
||||||
|
|
||||||
const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice)
|
const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice)
|
||||||
|
const resolveContrast = (choice) => (choice === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : choice)
|
||||||
|
|
||||||
window.Alpine.store('theme', {
|
window.Alpine.store('theme', {
|
||||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||||
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
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,
|
scheme: root.dataset.scheme || null,
|
||||||
|
|
||||||
get value() {
|
get value() {
|
||||||
@@ -54,6 +64,29 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.set(this.resolved === 'dark' ? 'light' : 'dark')
|
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) {
|
previewScheme(name) {
|
||||||
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
|
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
|
||||||
return
|
return
|
||||||
@@ -73,4 +106,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
theme.resolved = resolve('system')
|
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
|
(`$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
|
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
|
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
|
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
|
||||||
profile). A validation message for the bound property replaces the hint. --}}
|
profile). A validation message for the bound property replaces the hint. --}}
|
||||||
@@ -26,10 +29,32 @@
|
|||||||
$name ??= $model ?: 'scheme';
|
$name ??= $model ?: 'scheme';
|
||||||
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
|
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
|
||||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
$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
|
@endphp
|
||||||
|
|
||||||
@if ($profiles !== [])
|
@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))
|
@if (filled($label))
|
||||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||||
@endif
|
@endif
|
||||||
@@ -50,9 +75,13 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
|
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
|
||||||
@foreach (['primary', 'secondary', 'tertiary'] as $role)
|
@foreach ($roles as $role)
|
||||||
<span
|
<span
|
||||||
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
|
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)"
|
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>
|
></span>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -11,6 +11,17 @@
|
|||||||
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
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.
|
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
|
With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
|
||||||
active one — `Scheme::profile()`, which asks the application's resolver — is written to
|
active one — `Scheme::profile()`, which asks the application's resolver — is written to
|
||||||
<html data-scheme>, which the generated stylesheet keys each profile on.
|
<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
|
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
|
<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
|
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
|
<html data-scheme> (else the active one) and at the level in <html data-contrast>. A
|
||||||
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
|
MutationObserver on <html> keeps it in step with whatever changes `data-theme`,
|
||||||
an OS change while `system`, a profile preview, the application's own script. A layout's own
|
`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
|
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.
|
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
|
||||||
|
|
||||||
@@ -43,27 +55,44 @@
|
|||||||
@php
|
@php
|
||||||
$theme = config('livewire-material.theme');
|
$theme = config('livewire-material.theme');
|
||||||
$rail = config('livewire-material.rail');
|
$rail = config('livewire-material.rail');
|
||||||
|
$contrast = $theme['contrast'] ?? [];
|
||||||
|
$levels = \NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS;
|
||||||
|
|
||||||
$settings = [
|
$settings = [
|
||||||
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
|
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
|
||||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
'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' => [
|
'rail' => [
|
||||||
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
|
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
|
||||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
'key' => $rail['storage_key'] ?? 'material-rail',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
|
// 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)) {
|
if ((bool) ($theme['meta'] ?? false)) {
|
||||||
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
|
$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'] = [
|
$settings['meta'] = [
|
||||||
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
|
...$surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||||
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
'contrast' => $otherLevels(fn (string $level): array => $surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load(contrast: $level))),
|
||||||
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
|
'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
|
@endphp
|
||||||
@@ -72,8 +101,11 @@
|
|||||||
(function (settings) {
|
(function (settings) {
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
var media = window.matchMedia('(prefers-color-scheme: dark)');
|
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 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 choice = settings.default;
|
||||||
|
var contrast = settings.contrast.default;
|
||||||
var rail = settings.rail.default;
|
var rail = settings.rail.default;
|
||||||
var railChosen = false;
|
var railChosen = false;
|
||||||
|
|
||||||
@@ -85,6 +117,12 @@
|
|||||||
railChosen = true;
|
railChosen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var storedContrast = localStorage.getItem(settings.contrast.key);
|
||||||
|
|
||||||
|
if (validContrast(storedContrast)) {
|
||||||
|
contrast = storedContrast;
|
||||||
|
}
|
||||||
|
|
||||||
var stored = localStorage.getItem(settings.key);
|
var stored = localStorage.getItem(settings.key);
|
||||||
|
|
||||||
if (valid(stored)) {
|
if (valid(stored)) {
|
||||||
@@ -111,12 +149,30 @@
|
|||||||
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
|
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) {
|
if (settings.scheme) {
|
||||||
root.setAttribute('data-scheme', 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-key', settings.key);
|
||||||
root.setAttribute('data-theme-choice', choice);
|
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-key', settings.rail.key);
|
||||||
root.setAttribute('data-rail', rail);
|
root.setAttribute('data-rail', rail);
|
||||||
|
|
||||||
@@ -126,19 +182,23 @@
|
|||||||
root.setAttribute('data-rail-auto', '');
|
root.setAttribute('data-rail-auto', '');
|
||||||
}
|
}
|
||||||
apply();
|
apply();
|
||||||
|
applyContrast();
|
||||||
|
|
||||||
media.addEventListener('change', apply);
|
media.addEventListener('change', apply);
|
||||||
|
contrastMedia.addEventListener('change', applyContrast);
|
||||||
@if (isset($settings['meta']))
|
@if (isset($settings['meta']))
|
||||||
|
|
||||||
var paintThemeColor = function () {
|
var paintThemeColor = function () {
|
||||||
var theme = root.getAttribute('data-theme');
|
var theme = root.getAttribute('data-theme');
|
||||||
var scheme = root.getAttribute('data-scheme');
|
var scheme = root.getAttribute('data-scheme');
|
||||||
|
var level = root.getAttribute('data-contrast');
|
||||||
|
|
||||||
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
|
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
|
||||||
return;
|
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])');
|
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
|
||||||
|
|
||||||
if (metas.length === 0) {
|
if (metas.length === 0) {
|
||||||
@@ -157,13 +217,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
paintThemeColor();
|
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('DOMContentLoaded', paintThemeColor);
|
||||||
document.addEventListener('livewire:navigated', paintThemeColor);
|
document.addEventListener('livewire:navigated', paintThemeColor);
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
document.addEventListener('livewire:navigating', function (event) {
|
document.addEventListener('livewire:navigating', function (event) {
|
||||||
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-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)];
|
return [name, root.getAttribute(name)];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
"Dark theme", pressed while dark.
|
"Dark theme", pressed while dark.
|
||||||
- `cycle`: an icon button that steps light → dark → system, showing the choice it is on.
|
- `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.
|
- `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
|
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. --}}
|
Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}}
|
||||||
@@ -16,10 +20,37 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$mode = in_array($mode, ['toggle', 'cycle', 'picker'], true) ? $mode : 'toggle';
|
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
|
||||||
@endphp
|
@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
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="{{ __('Theme') }}"
|
aria-label="{{ __('Theme') }}"
|
||||||
|
|||||||
@@ -11,12 +11,21 @@
|
|||||||
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
'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 = [
|
$examples = [
|
||||||
'Colour profiles' => <<<'BLADE'
|
'Colour profiles' => <<<'BLADE'
|
||||||
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
|
<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()." />
|
<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>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Contrast level' => <<<'BLADE'
|
||||||
|
<x-theme-toggle mode="contrast" />
|
||||||
|
BLADE,
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@@ -34,10 +43,43 @@
|
|||||||
profiles the picker draws nothing.
|
profiles the picker draws nothing.
|
||||||
</p>
|
</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><html data-contrast></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)
|
@foreach ($examples as $title => $code)
|
||||||
<x-showcase::example :$title :$code />
|
<x-showcase::example :$title :$code />
|
||||||
@endforeach
|
@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">
|
<div class="grid gap-4 expanded:grid-cols-2">
|
||||||
@foreach (['light', 'dark'] as $theme)
|
@foreach (['light', 'dark'] as $theme)
|
||||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||||
|
|||||||
+112
-19
@@ -19,7 +19,8 @@ class SchemeCommand extends Command
|
|||||||
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
{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}
|
{--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)}
|
{--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}
|
{--success=#22a06b : The source of the success colour}
|
||||||
{--warning=#e2a400 : The source of the warning colour}
|
{--warning=#e2a400 : The source of the warning colour}
|
||||||
{--info=#1d7afc : The source of the info colour}
|
{--info=#1d7afc : The source of the info colour}
|
||||||
@@ -38,6 +39,16 @@ class SchemeCommand extends Command
|
|||||||
*/
|
*/
|
||||||
protected const SPECS = ['2021', '2025'];
|
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
|
public function handle(Filesystem $files): int
|
||||||
{
|
{
|
||||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||||
@@ -51,11 +62,16 @@ class SchemeCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filled($this->argument('seed'))) {
|
if (filled($this->argument('seed'))) {
|
||||||
|
if ($this->refusesContrast((float) $this->option('contrast'))) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
$input = [
|
$input = [
|
||||||
'seed' => (string) $this->argument('seed'),
|
'seed' => (string) $this->argument('seed'),
|
||||||
'variant' => (string) $this->option('variant'),
|
'variant' => (string) $this->option('variant'),
|
||||||
'spec' => $spec,
|
'spec' => $spec,
|
||||||
'contrast' => (float) $this->option('contrast'),
|
'contrast' => (float) $this->option('contrast'),
|
||||||
|
'harmonize' => (bool) $this->option('harmonize'),
|
||||||
'success' => (string) $this->option('success'),
|
'success' => (string) $this->option('success'),
|
||||||
'warning' => (string) $this->option('warning'),
|
'warning' => (string) $this->option('warning'),
|
||||||
'info' => (string) $this->option('info'),
|
'info' => (string) $this->option('info'),
|
||||||
@@ -87,12 +103,17 @@ class SchemeCommand extends Command
|
|||||||
return self::FAILURE;
|
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.
|
// A profile's own spec and state colours win; without them, the command's options apply.
|
||||||
$scheme = $this->generate([
|
$scheme = $this->generate([
|
||||||
'seed' => (string) ($profile['seed'] ?? ''),
|
'seed' => (string) ($profile['seed'] ?? ''),
|
||||||
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
|
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
|
||||||
'spec' => (string) ($profile['spec'] ?? $spec),
|
'spec' => (string) ($profile['spec'] ?? $spec),
|
||||||
'contrast' => (float) ($profile['contrast'] ?? 0),
|
'contrast' => (float) ($profile['contrast'] ?? 0),
|
||||||
|
'harmonize' => (bool) ($profile['harmonize'] ?? $this->option('harmonize')),
|
||||||
'success' => (string) ($profile['success'] ?? $this->option('success')),
|
'success' => (string) ($profile['success'] ?? $this->option('success')),
|
||||||
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
|
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
|
||||||
'info' => (string) ($profile['info'] ?? $this->option('info')),
|
'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.
|
* 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
|
* @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, contrast: float, light: array<string, string>, dark: array<string, string>}|null
|
* @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
|
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
|
* The stylesheet, headed by the command that regenerates it: every option that differs from
|
||||||
* its default is written out.
|
* 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, 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, success: string, warning: string, info: string} $input
|
* @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
|
protected function stylesheet(array $scheme, array $input): string
|
||||||
{
|
{
|
||||||
@@ -173,15 +216,16 @@ class SchemeCommand extends Command
|
|||||||
->implode('');
|
->implode('');
|
||||||
|
|
||||||
$command = sprintf(
|
$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['seed'],
|
||||||
$scheme['variant'],
|
$scheme['variant'],
|
||||||
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
||||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
$scheme['contrast']['standard'] != 0 ? ' --contrast='.$scheme['contrast']['standard'] : '',
|
||||||
|
$scheme['harmonize'] ? ' --harmonize' : '',
|
||||||
$states,
|
$states,
|
||||||
);
|
);
|
||||||
|
|
||||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
$blocks = $this->levels($scheme);
|
||||||
|
|
||||||
return <<<CSS
|
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
|
* 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
|
* M3's contrast guarantee only as generated. The head script sets data-theme before the
|
||||||
* first paint; the light block also stands without it.
|
* 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}
|
{$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
|
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
||||||
* page's profile.
|
* 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
|
protected function profilesStylesheet(array $profiles, string $default): string
|
||||||
{
|
{
|
||||||
$list = collect($profiles)
|
$list = collect($profiles)
|
||||||
->map(fn (array $profile, string $name): string => sprintf(
|
->map(fn (array $profile, string $name): string => sprintf(
|
||||||
' * %-12s %s, %s%s%s',
|
' * %-12s %s, %s%s%s%s',
|
||||||
$name,
|
$name,
|
||||||
$profile['seed'],
|
$profile['seed'],
|
||||||
$profile['variant'],
|
$profile['variant'],
|
||||||
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
|
$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");
|
->implode("\n");
|
||||||
|
|
||||||
@@ -232,25 +282,68 @@ class SchemeCommand extends Command
|
|||||||
{$list}
|
{$list}
|
||||||
*
|
*
|
||||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
* 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
|
* M3's contrast guarantee only as generated. The head script sets data-theme, data-scheme
|
||||||
* before the first paint.
|
* and data-contrast before the first paint.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
CSS;
|
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) {
|
foreach ($profiles as $name => $profile) {
|
||||||
$css .= "\n".$this->blocks(
|
$css .= "\n".$this->levels($profile, "[data-scheme='{$name}']");
|
||||||
["[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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $css;
|
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.
|
* A light and a dark block of roles under the given selectors.
|
||||||
*
|
*
|
||||||
|
|||||||
+50
-21
@@ -18,6 +18,11 @@ use Throwable;
|
|||||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
* 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.
|
* 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
|
* 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
|
* 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
|
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
|
||||||
@@ -30,6 +35,14 @@ use Throwable;
|
|||||||
*/
|
*/
|
||||||
class Scheme
|
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
|
* @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>}
|
* @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);
|
$data = static::data($path);
|
||||||
$profiles = static::profilesFrom($data);
|
$profiles = static::profilesFrom($data, $contrast);
|
||||||
|
|
||||||
if ($profiles !== []) {
|
if ($profiles !== []) {
|
||||||
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $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']];
|
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
|
||||||
}
|
}
|
||||||
|
|
||||||
$default = static::defaultScheme();
|
return static::scheme($data, $contrast);
|
||||||
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'light' => [...$default['light'], ...$scheme['light']],
|
|
||||||
'dark' => [...$default['dark'], ...$scheme['dark']],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,9 +83,9 @@ class Scheme
|
|||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @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>}>
|
* @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
|
* @param array<mixed> $data
|
||||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
* @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)) {
|
if (! is_array($data['profiles'] ?? null)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$default = static::defaultScheme();
|
|
||||||
$profiles = [];
|
$profiles = [];
|
||||||
|
|
||||||
foreach ($data['profiles'] as $name => $profile) {
|
foreach ($data['profiles'] as $name => $profile) {
|
||||||
@@ -132,14 +139,32 @@ class Scheme
|
|||||||
|
|
||||||
$profiles[$name] = [
|
$profiles[$name] = [
|
||||||
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
||||||
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
|
...static::scheme($profile, $contrast),
|
||||||
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $profiles;
|
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 array<mixed> $data
|
||||||
* @param non-empty-array<string, mixed> $profiles
|
* @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>}
|
* @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);
|
$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)],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ it('draws floating and docked toolbars', function () {
|
|||||||
->not->toContain('data-toolbar-group');
|
->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 () {
|
it('switches the theme through the store in three shapes', function () {
|
||||||
expect((string) $this->blade('<x-theme-toggle />'))
|
expect((string) $this->blade('<x-theme-toggle />'))
|
||||||
->toContain('data-theme-toggle="toggle"')
|
->toContain('data-theme-toggle="toggle"')
|
||||||
|
|||||||
@@ -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 () {
|
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" />');
|
$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, 'wire:model="colorProfile"'))->toBe(2)
|
||||||
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
|
->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 () {
|
it('keeps its inline styles to the scheme file\'s checked colours', function () {
|
||||||
File::put($this->path, json_encode([
|
File::put($this->path, json_encode([
|
||||||
'default' => 'indigo',
|
'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" />');
|
$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()
|
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 () {
|
it('shows a validation message for the bound property instead of the hint', function () {
|
||||||
|
|||||||
@@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
|
|||||||
|
|
||||||
it('follows the operating system until the visitor chooses, by default', function () {
|
it('follows the operating system until the visitor chooses, by default', function () {
|
||||||
$this->blade('<x-theme-script />')
|
$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 () {
|
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 () {
|
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
||||||
$this->blade('<x-theme-script />')
|
$this->blade('<x-theme-script />')
|
||||||
->assertSee("document.addEventListener('livewire:navigating'", false)
|
->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);
|
->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]);
|
config(['livewire-material.theme.meta' => true]);
|
||||||
|
|
||||||
$scheme = Scheme::load();
|
$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 />')
|
$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('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);
|
->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';
|
$path = sys_get_temp_dir().'/theme-script-meta-'.uniqid().'.json';
|
||||||
File::put($path, json_encode([
|
File::put($path, json_encode([
|
||||||
'default' => 'indigo',
|
'default' => 'indigo',
|
||||||
'profiles' => [
|
'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']],
|
'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']],
|
||||||
],
|
],
|
||||||
]));
|
]));
|
||||||
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
|
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
|
||||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
Scheme::resolveProfileUsing(fn (): string => 'indigo');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->blade('<x-theme-script />')
|
$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 {
|
} finally {
|
||||||
Scheme::resolveProfileUsing(null);
|
Scheme::resolveProfileUsing(null);
|
||||||
File::delete($path);
|
File::delete($path);
|
||||||
|
|||||||
@@ -86,6 +86,23 @@ function withPackageMailComponents(): void
|
|||||||
app()->forgetInstance(Markdown::class);
|
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 () {
|
it('inlines the application\'s scheme onto the mail', function () {
|
||||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||||
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
|
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
|
||||||
|
|||||||
@@ -3,6 +3,37 @@
|
|||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Str;
|
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 () {
|
beforeEach(function () {
|
||||||
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
|
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
|
||||||
$this->data = Str::replaceLast('.css', '.json', $this->stylesheet);
|
$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')
|
->seed->toBe('#4f46e5')
|
||||||
->variant->toBe('tonal-spot')
|
->variant->toBe('tonal-spot')
|
||||||
->spec->toBe('2025')
|
->spec->toBe('2025')
|
||||||
|
->harmonize->toBeFalse()
|
||||||
|
->and($scheme['contrast']['standard'])->toBe(0)
|
||||||
->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light']))
|
->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'])->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'])
|
->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');
|
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 () {
|
it('generates a different scheme per variant', function () {
|
||||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
|
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
|
||||||
->assertSuccessful();
|
->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'])->spec->toBe('2025')
|
||||||
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
|
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
|
||||||
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
|
->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))
|
->and(File::get($this->stylesheet))
|
||||||
->toContain('(spec 2025)')
|
->toContain('(spec 2025)')
|
||||||
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
|
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ function writeProfiles(string $path, array $primaries, string $default): void
|
|||||||
'contrast' => 0,
|
'contrast' => 0,
|
||||||
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
|
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
|
||||||
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
|
'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();
|
])->all();
|
||||||
|
|
||||||
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
|
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');
|
->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 () {
|
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']]));
|
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
|||||||
@@ -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 () {
|
it('turns every role into a Tailwind colour', function () {
|
||||||
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
|
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
|
||||||
$theme = File::get(packageCss('tokens/theme.css'));
|
$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*#/');
|
->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) {
|
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
Reference in New Issue
Block a user