Give success, warning and info the 2025 spec and three contrast levels
The three state colours are DynamicColors on their own tonal palette now, built exactly as Google builds error/on-error/error-container/on-error-container in both specs, so the scheme's contrast level, dark tones, spec version and platform reach them as they reach every other role; --harmonize (and a profile's harmonize) pulls each source towards the seed, off by default. material:scheme also writes M3's medium (0.5) and high (1.0) levels for both themes and every profile, keyed on data-contrast and never on a media query, and --contrast now moves the standard block alone. Scheme::load() takes the level; the mail theme stays on standard. Plan: docs/plans/material-3-alignment.md, steps 6 and 7 (core C1, C2, C15). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b1fc0c9cfa
commit
4263982369
+211
-30
@@ -4,15 +4,23 @@
|
||||
* Rebuild with `npm run build:scheme` after changing this file or upgrading
|
||||
* @material/material-color-utilities; the bundle is committed so applications need
|
||||
* nothing but `node`. (The published library imports without file extensions, which
|
||||
* plain Node refuses, so it cannot be run unbundled anyway.)
|
||||
* plain Node refuses, so it cannot be run unbundled anyway — and two of the classes
|
||||
* below are imported by path, which only a bundler resolves.)
|
||||
*
|
||||
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
|
||||
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
|
||||
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
||||
* Input: one JSON argument — {seed, variant, spec, contrast, harmonize, success, warning,
|
||||
* info}. `spec` is the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it
|
||||
* first shipped). `contrast` is the standard level, below M3's medium (0.5).
|
||||
* Output: JSON on stdout — {seed, variant, spec, harmonize, contrast: {standard, medium:
|
||||
* {light, dark}, high: {light, dark}}, light: {role: hex}, dark: {role: hex}}. The top-level
|
||||
* light and dark are the standard level; medium (0.5) and high (1.0) are M3's other two
|
||||
* contrast levels, generated for both themes so a stylesheet can key them on an attribute.
|
||||
*/
|
||||
import {
|
||||
argbFromHex,
|
||||
customColor,
|
||||
Blend,
|
||||
clampDouble,
|
||||
DynamicColor,
|
||||
extendSpecVersion,
|
||||
hexFromArgb,
|
||||
Hct,
|
||||
MaterialDynamicColors,
|
||||
@@ -25,7 +33,12 @@ import {
|
||||
SchemeRainbow,
|
||||
SchemeTonalSpot,
|
||||
SchemeVibrant,
|
||||
TonalPalette,
|
||||
Variant,
|
||||
} from '@material/material-color-utilities'
|
||||
// Google exports neither from the package's index; the bundler resolves the path.
|
||||
import { ContrastCurve } from '../node_modules/@material/material-color-utilities/dynamiccolor/contrast_curve.js'
|
||||
import { ToneDeltaPair } from '../node_modules/@material/material-color-utilities/dynamiccolor/tone_delta_pair.js'
|
||||
|
||||
const VARIANTS = {
|
||||
'tonal-spot': SchemeTonalSpot,
|
||||
@@ -39,9 +52,15 @@ const VARIANTS = {
|
||||
'fruit-salad': SchemeFruitSalad,
|
||||
}
|
||||
|
||||
// M3's three contrast levels (styles/color/roles, "What's new May 2025"): standard is the
|
||||
// scheme's own level, medium and high are fixed. Every level is generated for both themes.
|
||||
const LEVELS = { standard: 0, medium: 0.5, high: 1 }
|
||||
|
||||
// Key colours seed the palettes; they are not roles a stylesheet paints with.
|
||||
const NOT_ROLES = /_palette_key_color$/
|
||||
|
||||
const STATES = ['success', 'warning', 'info']
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`)
|
||||
process.exit(1)
|
||||
@@ -64,22 +83,179 @@ if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${inp
|
||||
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
||||
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
|
||||
|
||||
for (const state of ['success', 'warning', 'info']) {
|
||||
for (const state of STATES) {
|
||||
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
||||
}
|
||||
|
||||
const contrast = Number(input.contrast ?? 0)
|
||||
const harmonize = Boolean(input.harmonize ?? false)
|
||||
|
||||
if (!(contrast >= -1 && contrast <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
|
||||
if (!(contrast >= -1 && contrast < LEVELS.medium)) {
|
||||
fail(`The standard contrast level runs from -1 to below ${LEVELS.medium}, "${input.contrast}" given; medium and high are generated as their own blocks.`)
|
||||
}
|
||||
|
||||
const source = Hct.fromInt(argbFromHex(input.seed))
|
||||
const colors = new MaterialDynamicColors()
|
||||
|
||||
function roles(isDark) {
|
||||
/**
|
||||
* The contrast curves of Google's 2025 spec, from its private getCurve()
|
||||
* (dynamiccolor/color_spec_2025.js).
|
||||
*/
|
||||
function curve(defaultContrast) {
|
||||
return {
|
||||
1.5: new ContrastCurve(1.5, 1.5, 3, 5.5),
|
||||
3: new ContrastCurve(3, 3, 4.5, 7),
|
||||
4.5: new ContrastCurve(4.5, 4.5, 7, 11),
|
||||
6: new ContrastCurve(6, 6, 7, 11),
|
||||
7: new ContrastCurve(7, 7, 11, 21),
|
||||
}[defaultContrast]
|
||||
}
|
||||
|
||||
/** The tone that holds the palette's chroma, searching up or down — Google's findBestToneForChroma. */
|
||||
function bestToneForChroma(hue, chroma, tone, byDecreasingTone) {
|
||||
let answer = tone
|
||||
let best = Hct.from(hue, chroma, answer)
|
||||
|
||||
while (best.chroma < chroma) {
|
||||
if (tone < 0 || tone > 100) break
|
||||
|
||||
tone += byDecreasingTone ? -1 : 1
|
||||
|
||||
const candidate = Hct.from(hue, chroma, tone)
|
||||
|
||||
if (best.chroma < candidate.chroma) {
|
||||
best = candidate
|
||||
answer = tone
|
||||
}
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
/** Google's tMaxC: the most chromatic tone, from the top. */
|
||||
function tMaxC(palette, lowerBound = 0, upperBound = 100) {
|
||||
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 100, true))
|
||||
}
|
||||
|
||||
/** Google's tMinC: the most chromatic tone, from the bottom. */
|
||||
function tMinC(palette, lowerBound = 0, upperBound = 100) {
|
||||
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 0, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* success, warning and info as M3 custom colours: the four roles of a semantic colour, built on
|
||||
* their own tonal palette exactly as Google builds error / on-error / error-container /
|
||||
* on-error-container — the same tones, contrast curves and tone delta pairs in both specs
|
||||
* (dynamiccolor/color_spec_2021.js and color_spec_2025.js, "Errors [E]"). Because they are
|
||||
* DynamicColors against the page's own DynamicScheme, they follow its contrast level, its dark
|
||||
* tones, its spec version and its platform the way every other role does; the 2021 recipe they
|
||||
* used before (customColor) was fixed at tones 40/100/90/10 whatever the scheme asked for.
|
||||
*/
|
||||
function stateRoles(name, palette) {
|
||||
const of = () => palette
|
||||
let color, onColor, container, onContainer
|
||||
|
||||
color = extendSpecVersion(
|
||||
DynamicColor.fromPalette({
|
||||
name,
|
||||
palette: of,
|
||||
tone: (s) => (s.isDark ? 80 : 40),
|
||||
isBackground: true,
|
||||
background: (s) => colors.highestSurface(s),
|
||||
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 7),
|
||||
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
|
||||
}),
|
||||
'2025',
|
||||
DynamicColor.fromPalette({
|
||||
name,
|
||||
palette: of,
|
||||
tone: (s) => (s.platform === 'phone' ? (s.isDark ? tMinC(palette, 0, 98) : tMaxC(palette)) : tMinC(palette)),
|
||||
isBackground: true,
|
||||
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : colors.surfaceContainerHigh()),
|
||||
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
|
||||
toneDeltaPair: (s) => (s.platform === 'phone' ? new ToneDeltaPair(container, color, 5, 'relative_lighter', true, 'farther') : undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
onColor = extendSpecVersion(
|
||||
DynamicColor.fromPalette({
|
||||
name: `on_${name}`,
|
||||
palette: of,
|
||||
tone: (s) => (s.isDark ? 20 : 100),
|
||||
background: () => color,
|
||||
contrastCurve: () => new ContrastCurve(4.5, 7, 11, 21),
|
||||
}),
|
||||
'2025',
|
||||
DynamicColor.fromPalette({
|
||||
name: `on_${name}`,
|
||||
palette: of,
|
||||
// Google's on-error sits on error-dim off the phone; a state colour has no dim role.
|
||||
background: () => color,
|
||||
contrastCurve: (s) => (s.platform === 'phone' ? curve(6) : curve(7)),
|
||||
}),
|
||||
)
|
||||
|
||||
container = extendSpecVersion(
|
||||
DynamicColor.fromPalette({
|
||||
name: `${name}_container`,
|
||||
palette: of,
|
||||
tone: (s) => (s.isDark ? 30 : 90),
|
||||
isBackground: true,
|
||||
background: (s) => colors.highestSurface(s),
|
||||
contrastCurve: () => new ContrastCurve(1, 1, 3, 4.5),
|
||||
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
|
||||
}),
|
||||
'2025',
|
||||
DynamicColor.fromPalette({
|
||||
name: `${name}_container`,
|
||||
palette: of,
|
||||
tone: (s) => (s.platform === 'watch' ? 30 : s.isDark ? tMinC(palette, 30, 93) : tMaxC(palette, 0, 90)),
|
||||
isBackground: true,
|
||||
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : undefined),
|
||||
toneDeltaPair: (s) => (s.platform === 'watch' ? new ToneDeltaPair(container, color, 10, 'darker', true, 'farther') : undefined),
|
||||
contrastCurve: (s) => (s.platform === 'phone' && s.contrastLevel > 0 ? curve(1.5) : undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
onContainer = extendSpecVersion(
|
||||
DynamicColor.fromPalette({
|
||||
name: `on_${name}_container`,
|
||||
palette: of,
|
||||
tone: (s) => (s.variant === Variant.MONOCHROME ? (s.isDark ? 90 : 10) : s.isDark ? 90 : 30),
|
||||
background: () => container,
|
||||
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 11),
|
||||
}),
|
||||
'2025',
|
||||
DynamicColor.fromPalette({
|
||||
name: `on_${name}_container`,
|
||||
palette: of,
|
||||
background: () => container,
|
||||
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
[name]: color,
|
||||
[`on-${name}`]: onColor,
|
||||
[`${name}-container`]: container,
|
||||
[`on-${name}-container`]: onContainer,
|
||||
}
|
||||
}
|
||||
|
||||
// Harmonisation pulls a state's hue towards the seed, so the three read as part of the scheme
|
||||
// (styles/color/advanced/define-new-colors). Off by default: a state has to stay recognisable
|
||||
// whatever the brand is, and a green that has turned blue no longer says "success".
|
||||
const states = Object.assign({}, ...STATES.map((state) => {
|
||||
const value = argbFromHex(input[state])
|
||||
|
||||
return stateRoles(state, TonalPalette.fromInt(harmonize ? Blend.harmonize(value, argbFromHex(input.seed)) : value))
|
||||
}))
|
||||
|
||||
function roles(isDark, contrastLevel) {
|
||||
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
||||
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
|
||||
// M3's original colour, for an application whose palette was generated with it.
|
||||
const scheme = new Scheme(source, isDark, contrast, spec)
|
||||
const scheme = new Scheme(source, isDark, contrastLevel, spec)
|
||||
const out = {}
|
||||
|
||||
for (const color of colors.allColors) {
|
||||
@@ -88,36 +264,41 @@ function roles(isDark) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [role, color] of Object.entries(states)) {
|
||||
out[role] = hexFromArgb(color.getArgb(scheme))
|
||||
}
|
||||
|
||||
return { scheme, out }
|
||||
}
|
||||
|
||||
const light = roles(false)
|
||||
const dark = roles(true)
|
||||
/** One contrast level: both themes, with each theme's inverse state colours from the other. */
|
||||
function level(contrastLevel) {
|
||||
const light = roles(false, contrastLevel)
|
||||
const dark = roles(true, contrastLevel)
|
||||
|
||||
// success, warning and info are M3 custom colours, harmonisation off: harmonising pulls
|
||||
// each hue towards the seed, and a state has to stay recognisable whatever the brand is.
|
||||
for (const state of ['success', 'warning', 'info']) {
|
||||
const group = customColor(argbFromHex(input.seed), { name: state, value: argbFromHex(input[state]), blend: false })
|
||||
|
||||
for (const [theme, colours] of [['light', light.out], ['dark', dark.out]]) {
|
||||
colours[state] = hexFromArgb(group[theme].color)
|
||||
colours[`on-${state}`] = hexFromArgb(group[theme].onColor)
|
||||
colours[`${state}-container`] = hexFromArgb(group[theme].colorContainer)
|
||||
colours[`on-${state}-container`] = hexFromArgb(group[theme].onColorContainer)
|
||||
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
|
||||
for (const state of ['error', ...STATES]) {
|
||||
light.out[`inverse-${state}`] = dark.out[state]
|
||||
dark.out[`inverse-${state}`] = light.out[state]
|
||||
}
|
||||
|
||||
return { spec: light.scheme.specVersion, light: light.out, dark: dark.out }
|
||||
}
|
||||
|
||||
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
|
||||
for (const state of ['error', 'success', 'warning', 'info']) {
|
||||
light.out[`inverse-${state}`] = dark.out[state]
|
||||
dark.out[`inverse-${state}`] = light.out[state]
|
||||
}
|
||||
const standard = level(contrast)
|
||||
const medium = level(LEVELS.medium)
|
||||
const high = level(LEVELS.high)
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
seed: input.seed.toLowerCase(),
|
||||
variant: input.variant,
|
||||
spec: light.scheme.specVersion,
|
||||
contrast,
|
||||
light: light.out,
|
||||
dark: dark.out,
|
||||
spec: standard.spec,
|
||||
harmonize,
|
||||
contrast: {
|
||||
standard: contrast,
|
||||
medium: { light: medium.light, dark: medium.dark },
|
||||
high: { light: high.light, dark: high.dark },
|
||||
},
|
||||
light: standard.light,
|
||||
dark: standard.dark,
|
||||
}))
|
||||
|
||||
+320
-29
@@ -6,6 +6,11 @@
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme before the
|
||||
* first paint; the light block also stands without it.
|
||||
*
|
||||
* M3's medium and high contrast levels follow the standard blocks, keyed on
|
||||
* data-contrast — which the head script writes from the visitor's choice, or from the
|
||||
* operating system's contrast setting while that choice is `system`. No media query
|
||||
* decides here, as none decides the theme.
|
||||
*/
|
||||
|
||||
:root,
|
||||
@@ -61,22 +66,22 @@
|
||||
--md-sys-color-on-error: #fff7f7;
|
||||
--md-sys-color-error-container: #f97386;
|
||||
--md-sys-color-on-error-container: #6e0523;
|
||||
--md-sys-color-success: #006c45;
|
||||
--md-sys-color-on-success: #ffffff;
|
||||
--md-sys-color-success: #006d46;
|
||||
--md-sys-color-on-success: #e7ffed;
|
||||
--md-sys-color-success-container: #86f9bc;
|
||||
--md-sys-color-on-success-container: #002112;
|
||||
--md-sys-color-warning: #7c5800;
|
||||
--md-sys-color-on-warning: #ffffff;
|
||||
--md-sys-color-warning-container: #ffdea6;
|
||||
--md-sys-color-on-warning-container: #271900;
|
||||
--md-sys-color-info: #005ac4;
|
||||
--md-sys-color-on-info: #ffffff;
|
||||
--md-sys-color-info-container: #d8e2ff;
|
||||
--md-sys-color-on-info-container: #001a42;
|
||||
--md-sys-color-on-success-container: #00734a;
|
||||
--md-sys-color-warning: #7c5900;
|
||||
--md-sys-color-on-warning: #fff8f1;
|
||||
--md-sys-color-warning-container: #fab925;
|
||||
--md-sys-color-on-warning-container: #6a4b00;
|
||||
--md-sys-color-info: #005bc5;
|
||||
--md-sys-color-on-info: #f9f8ff;
|
||||
--md-sys-color-info-container: #1c7afc;
|
||||
--md-sys-color-on-info-container: #001435;
|
||||
--md-sys-color-inverse-error: #f97386;
|
||||
--md-sys-color-inverse-success: #69dca1;
|
||||
--md-sys-color-inverse-warning: #fdbb28;
|
||||
--md-sys-color-inverse-info: #aec6ff;
|
||||
--md-sys-color-inverse-success: #3bb27b;
|
||||
--md-sys-color-inverse-warning: #f3b31e;
|
||||
--md-sys-color-inverse-info: #699cff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
@@ -131,20 +136,306 @@
|
||||
--md-sys-color-on-error: #490013;
|
||||
--md-sys-color-error-container: #871c34;
|
||||
--md-sys-color-on-error-container: #ff97a3;
|
||||
--md-sys-color-success: #69dca1;
|
||||
--md-sys-color-on-success: #003822;
|
||||
--md-sys-color-success-container: #005233;
|
||||
--md-sys-color-on-success-container: #86f9bc;
|
||||
--md-sys-color-warning: #fdbb28;
|
||||
--md-sys-color-on-warning: #412d00;
|
||||
--md-sys-color-warning-container: #5e4200;
|
||||
--md-sys-color-on-warning-container: #ffdea6;
|
||||
--md-sys-color-info: #aec6ff;
|
||||
--md-sys-color-on-info: #002e6a;
|
||||
--md-sys-color-info-container: #004396;
|
||||
--md-sys-color-on-info-container: #d8e2ff;
|
||||
--md-sys-color-success: #3bb27b;
|
||||
--md-sys-color-on-success: #002615;
|
||||
--md-sys-color-success-container: #059460;
|
||||
--md-sys-color-on-success-container: #001e10;
|
||||
--md-sys-color-warning: #f3b31e;
|
||||
--md-sys-color-on-warning: #4e3600;
|
||||
--md-sys-color-warning-container: #e4a604;
|
||||
--md-sys-color-on-warning-container: #593f00;
|
||||
--md-sys-color-info: #699cff;
|
||||
--md-sys-color-on-info: #001e4b;
|
||||
--md-sys-color-info-container: #0c74f6;
|
||||
--md-sys-color-on-info-container: #000a23;
|
||||
--md-sys-color-inverse-error: #a8364b;
|
||||
--md-sys-color-inverse-success: #006c45;
|
||||
--md-sys-color-inverse-warning: #7c5800;
|
||||
--md-sys-color-inverse-info: #005ac4;
|
||||
--md-sys-color-inverse-success: #006d46;
|
||||
--md-sys-color-inverse-warning: #7c5900;
|
||||
--md-sys-color-inverse-info: #005bc5;
|
||||
}
|
||||
|
||||
[data-contrast='medium'],
|
||||
[data-contrast='medium'][data-theme='light'],
|
||||
[data-contrast='medium'] [data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--md-sys-color-background: #fdf7fe;
|
||||
--md-sys-color-on-background: #25232b;
|
||||
--md-sys-color-surface: #fdf7fe;
|
||||
--md-sys-color-surface-dim: #ded8e4;
|
||||
--md-sys-color-surface-bright: #fdf7fe;
|
||||
--md-sys-color-surface-container-lowest: #ffffff;
|
||||
--md-sys-color-surface-container-low: #f8f1fa;
|
||||
--md-sys-color-surface-container: #f2ecf5;
|
||||
--md-sys-color-surface-container-high: #ece6f0;
|
||||
--md-sys-color-surface-container-highest: #e7e0ec;
|
||||
--md-sys-color-on-surface: #25232b;
|
||||
--md-sys-color-on-surface-variant: #45414b;
|
||||
--md-sys-color-outline: #615d68;
|
||||
--md-sys-color-outline-variant: #7d7983;
|
||||
--md-sys-color-inverse-surface: #0f0d12;
|
||||
--md-sys-color-inverse-on-surface: #c8c3c9;
|
||||
--md-sys-color-primary: #483b6b;
|
||||
--md-sys-color-primary-dim: #3c305f;
|
||||
--md-sys-color-on-primary: #e1d3ff;
|
||||
--md-sys-color-primary-container: #7b6da0;
|
||||
--md-sys-color-on-primary-container: #ffffff;
|
||||
--md-sys-color-primary-fixed: #7b6da0;
|
||||
--md-sys-color-primary-fixed-dim: #6e6093;
|
||||
--md-sys-color-on-primary-fixed: #ffffff;
|
||||
--md-sys-color-on-primary-fixed-variant: #ffffff;
|
||||
--md-sys-color-inverse-primary: #d4c3fd;
|
||||
--md-sys-color-secondary: #464054;
|
||||
--md-sys-color-secondary-dim: #3a3548;
|
||||
--md-sys-color-on-secondary: #dfd5ef;
|
||||
--md-sys-color-secondary-container: #787187;
|
||||
--md-sys-color-on-secondary-container: #ffffff;
|
||||
--md-sys-color-secondary-fixed: #787187;
|
||||
--md-sys-color-secondary-fixed-dim: #6c657b;
|
||||
--md-sys-color-on-secondary-fixed: #ffffff;
|
||||
--md-sys-color-on-secondary-fixed-variant: #ffffff;
|
||||
--md-sys-color-tertiary: #5c3653;
|
||||
--md-sys-color-tertiary-dim: #502b47;
|
||||
--md-sys-color-on-tertiary: #ffcaee;
|
||||
--md-sys-color-tertiary-container: #936787;
|
||||
--md-sys-color-on-tertiary-container: #ffffff;
|
||||
--md-sys-color-tertiary-fixed: #936787;
|
||||
--md-sys-color-tertiary-fixed-dim: #855b7a;
|
||||
--md-sys-color-on-tertiary-fixed: #ffffff;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #ffffff;
|
||||
--md-sys-color-error: #821830;
|
||||
--md-sys-color-error-dim: #6b0221;
|
||||
--md-sys-color-on-error: #ffcdd1;
|
||||
--md-sys-color-error-container: #c44b5f;
|
||||
--md-sys-color-on-error-container: #ffffff;
|
||||
--md-sys-color-success: #004d30;
|
||||
--md-sys-color-on-success: #7df0b3;
|
||||
--md-sys-color-success-container: #008656;
|
||||
--md-sys-color-on-success-container: #ffffff;
|
||||
--md-sys-color-warning: #593e00;
|
||||
--md-sys-color-on-warning: #ffd385;
|
||||
--md-sys-color-warning-container: #986d00;
|
||||
--md-sys-color-on-warning-container: #ffffff;
|
||||
--md-sys-color-info: #003f8e;
|
||||
--md-sys-color-on-info: #cbd9ff;
|
||||
--md-sys-color-info-container: #006fef;
|
||||
--md-sys-color-on-info-container: #ffffff;
|
||||
--md-sys-color-inverse-error: #ff9da8;
|
||||
--md-sys-color-inverse-success: #5ace94;
|
||||
--md-sys-color-inverse-warning: #f3b31e;
|
||||
--md-sys-color-inverse-info: #98b8ff;
|
||||
}
|
||||
|
||||
[data-contrast='medium'][data-theme='dark'],
|
||||
[data-contrast='medium'] [data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--md-sys-color-background: #0f0d12;
|
||||
--md-sys-color-on-background: #ffffff;
|
||||
--md-sys-color-surface: #0f0d12;
|
||||
--md-sys-color-surface-dim: #0f0d12;
|
||||
--md-sys-color-surface-bright: #2e2b34;
|
||||
--md-sys-color-surface-container-lowest: #000000;
|
||||
--md-sys-color-surface-container-low: #141218;
|
||||
--md-sys-color-surface-container: #1b181f;
|
||||
--md-sys-color-surface-container-high: #211e26;
|
||||
--md-sys-color-surface-container-highest: #27242d;
|
||||
--md-sys-color-on-surface: #ffffff;
|
||||
--md-sys-color-on-surface-variant: #bcb6c2;
|
||||
--md-sys-color-outline: #96919c;
|
||||
--md-sys-color-outline-variant: #78737e;
|
||||
--md-sys-color-inverse-surface: #fdf7fe;
|
||||
--md-sys-color-inverse-on-surface: #39373c;
|
||||
--md-sys-color-primary: #cdc0ec;
|
||||
--md-sys-color-primary-dim: #bfb2de;
|
||||
--md-sys-color-on-primary: #3a3054;
|
||||
--md-sys-color-primary-container: #7a6f96;
|
||||
--md-sys-color-on-primary-container: #ffffff;
|
||||
--md-sys-color-primary-fixed: #ded0fe;
|
||||
--md-sys-color-primary-fixed-dim: #d0c3ef;
|
||||
--md-sys-color-on-primary-fixed: #180e30;
|
||||
--md-sys-color-on-primary-fixed-variant: #3c3256;
|
||||
--md-sys-color-inverse-primary: #5a4f75;
|
||||
--md-sys-color-secondary: #cbc2db;
|
||||
--md-sys-color-secondary-dim: #beb5cd;
|
||||
--md-sys-color-on-secondary: #393347;
|
||||
--md-sys-color-secondary-container: #787187;
|
||||
--md-sys-color-on-secondary-container: #ffffff;
|
||||
--md-sys-color-secondary-fixed: #e8def8;
|
||||
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||
--md-sys-color-on-secondary-fixed: #221d2f;
|
||||
--md-sys-color-on-secondary-fixed-variant: #423c50;
|
||||
--md-sys-color-tertiary: #ffcfef;
|
||||
--md-sys-color-tertiary-dim: #f4bfe3;
|
||||
--md-sys-color-on-tertiary: #5e3855;
|
||||
--md-sys-color-tertiary-container: #f4bfe3;
|
||||
--md-sys-color-on-tertiary-container: #542f4c;
|
||||
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||
--md-sys-color-on-tertiary-fixed: #180015;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #4a2642;
|
||||
--md-sys-color-error: #ff9da8;
|
||||
--md-sys-color-error-dim: #ff8695;
|
||||
--md-sys-color-on-error: #5f001c;
|
||||
--md-sys-color-error-container: #c44b5f;
|
||||
--md-sys-color-on-error-container: #ffffff;
|
||||
--md-sys-color-success: #5ace94;
|
||||
--md-sys-color-on-success: #00341e;
|
||||
--md-sys-color-success-container: #008656;
|
||||
--md-sys-color-on-success-container: #ffffff;
|
||||
--md-sys-color-warning: #f3b31e;
|
||||
--md-sys-color-on-warning: #412d00;
|
||||
--md-sys-color-warning-container: #e4a604;
|
||||
--md-sys-color-on-warning-container: #332200;
|
||||
--md-sys-color-info: #98b8ff;
|
||||
--md-sys-color-on-info: #002a62;
|
||||
--md-sys-color-info-container: #006fef;
|
||||
--md-sys-color-on-info-container: #ffffff;
|
||||
--md-sys-color-inverse-error: #821830;
|
||||
--md-sys-color-inverse-success: #004d30;
|
||||
--md-sys-color-inverse-warning: #593e00;
|
||||
--md-sys-color-inverse-info: #003f8e;
|
||||
}
|
||||
|
||||
[data-contrast='high'],
|
||||
[data-contrast='high'][data-theme='light'],
|
||||
[data-contrast='high'] [data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--md-sys-color-background: #fdf7fe;
|
||||
--md-sys-color-on-background: #000000;
|
||||
--md-sys-color-surface: #fdf7fe;
|
||||
--md-sys-color-surface-dim: #ded8e4;
|
||||
--md-sys-color-surface-bright: #fdf7fe;
|
||||
--md-sys-color-surface-container-lowest: #ffffff;
|
||||
--md-sys-color-surface-container-low: #f8f1fa;
|
||||
--md-sys-color-surface-container: #f2ecf5;
|
||||
--md-sys-color-surface-container-high: #ece6f0;
|
||||
--md-sys-color-surface-container-highest: #e7e0ec;
|
||||
--md-sys-color-on-surface: #000000;
|
||||
--md-sys-color-on-surface-variant: #25232b;
|
||||
--md-sys-color-outline: #45414b;
|
||||
--md-sys-color-outline-variant: #54505a;
|
||||
--md-sys-color-inverse-surface: #0f0d12;
|
||||
--md-sys-color-inverse-on-surface: #ffffff;
|
||||
--md-sys-color-primary: #281b49;
|
||||
--md-sys-color-primary-dim: #1e103f;
|
||||
--md-sys-color-on-primary: #e1d3ff;
|
||||
--md-sys-color-primary-container: #584a7b;
|
||||
--md-sys-color-on-primary-container: #ffffff;
|
||||
--md-sys-color-primary-fixed: #584a7b;
|
||||
--md-sys-color-primary-fixed-dim: #4c3f6f;
|
||||
--md-sys-color-on-primary-fixed: #ffffff;
|
||||
--md-sys-color-on-primary-fixed-variant: #ffffff;
|
||||
--md-sys-color-inverse-primary: #d4c3fd;
|
||||
--md-sys-color-secondary: #262134;
|
||||
--md-sys-color-secondary-dim: #1c1729;
|
||||
--md-sys-color-on-secondary: #dfd6ef;
|
||||
--md-sys-color-secondary-container: #554f64;
|
||||
--md-sys-color-on-secondary-container: #ffffff;
|
||||
--md-sys-color-secondary-fixed: #554f64;
|
||||
--md-sys-color-secondary-fixed-dim: #494358;
|
||||
--md-sys-color-on-secondary-fixed: #ffffff;
|
||||
--md-sys-color-on-secondary-fixed-variant: #ffffff;
|
||||
--md-sys-color-tertiary: #391733;
|
||||
--md-sys-color-tertiary-dim: #2e0d28;
|
||||
--md-sys-color-on-tertiary: #ffcbee;
|
||||
--md-sys-color-tertiary-container: #6d4563;
|
||||
--md-sys-color-on-tertiary-container: #ffffff;
|
||||
--md-sys-color-tertiary-fixed: #6d4563;
|
||||
--md-sys-color-tertiary-fixed-dim: #603a57;
|
||||
--md-sys-color-on-tertiary-fixed: #ffffff;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #ffffff;
|
||||
--md-sys-color-error: #500016;
|
||||
--md-sys-color-error-dim: #3d000f;
|
||||
--md-sys-color-on-error: #ffced2;
|
||||
--md-sys-color-error-container: #97283e;
|
||||
--md-sys-color-on-error-container: #ffffff;
|
||||
--md-sys-color-success: #002a18;
|
||||
--md-sys-color-on-success: #7df0b4;
|
||||
--md-sys-color-success-container: #005f3c;
|
||||
--md-sys-color-on-success-container: #ffffff;
|
||||
--md-sys-color-warning: #312100;
|
||||
--md-sys-color-on-warning: #ffd486;
|
||||
--md-sys-color-warning-container: #6c4d00;
|
||||
--md-sys-color-on-warning-container: #ffffff;
|
||||
--md-sys-color-info: #002252;
|
||||
--md-sys-color-on-info: #ccdaff;
|
||||
--md-sys-color-info-container: #004eac;
|
||||
--md-sys-color-on-info-container: #ffffff;
|
||||
--md-sys-color-inverse-error: #ffdddf;
|
||||
--md-sys-color-inverse-success: #89fcbf;
|
||||
--md-sys-color-inverse-warning: #ffe2b1;
|
||||
--md-sys-color-inverse-info: #dde5ff;
|
||||
}
|
||||
|
||||
[data-contrast='high'][data-theme='dark'],
|
||||
[data-contrast='high'] [data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--md-sys-color-background: #0f0d12;
|
||||
--md-sys-color-on-background: #ffffff;
|
||||
--md-sys-color-surface: #0f0d12;
|
||||
--md-sys-color-surface-dim: #0f0d12;
|
||||
--md-sys-color-surface-bright: #2e2b34;
|
||||
--md-sys-color-surface-container-lowest: #000000;
|
||||
--md-sys-color-surface-container-low: #141218;
|
||||
--md-sys-color-surface-container: #1b181f;
|
||||
--md-sys-color-surface-container-high: #211e26;
|
||||
--md-sys-color-surface-container-highest: #27242d;
|
||||
--md-sys-color-on-surface: #ffffff;
|
||||
--md-sys-color-on-surface-variant: #eae3ef;
|
||||
--md-sys-color-outline: #bcb6c2;
|
||||
--md-sys-color-outline-variant: #a7a1ad;
|
||||
--md-sys-color-inverse-surface: #fdf7fe;
|
||||
--md-sys-color-inverse-on-surface: #000000;
|
||||
--md-sys-color-primary: #ebe1ff;
|
||||
--md-sys-color-primary-dim: #ded0fe;
|
||||
--md-sys-color-on-primary: #302649;
|
||||
--md-sys-color-primary-container: #aa9dc8;
|
||||
--md-sys-color-on-primary-container: #000000;
|
||||
--md-sys-color-primary-fixed: #ded0fe;
|
||||
--md-sys-color-primary-fixed-dim: #d0c3ef;
|
||||
--md-sys-color-on-primary-fixed: #000000;
|
||||
--md-sys-color-on-primary-fixed-variant: #180e30;
|
||||
--md-sys-color-inverse-primary: #3c3256;
|
||||
--md-sys-color-secondary: #ebe1fb;
|
||||
--md-sys-color-secondary-dim: #ddd3ed;
|
||||
--md-sys-color-on-secondary: #2e293c;
|
||||
--md-sys-color-secondary-container: #a8a0b8;
|
||||
--md-sys-color-on-secondary-container: #000000;
|
||||
--md-sys-color-secondary-fixed: #e8def8;
|
||||
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||
--md-sys-color-on-secondary-fixed: #000000;
|
||||
--md-sys-color-on-secondary-fixed-variant: #221d2f;
|
||||
--md-sys-color-tertiary: #ffdbf2;
|
||||
--md-sys-color-tertiary-dim: #fac5e9;
|
||||
--md-sys-color-on-tertiary: #43203b;
|
||||
--md-sys-color-tertiary-container: #f4bfe3;
|
||||
--md-sys-color-on-tertiary-container: #2e0e29;
|
||||
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||
--md-sys-color-on-tertiary-fixed: #000000;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #180015;
|
||||
--md-sys-color-error: #ffdddf;
|
||||
--md-sys-color-error-dim: #ffc7cb;
|
||||
--md-sys-color-on-error: #5f001c;
|
||||
--md-sys-color-error-container: #ff798c;
|
||||
--md-sys-color-on-error-container: #000000;
|
||||
--md-sys-color-success: #89fcbf;
|
||||
--md-sys-color-on-success: #00341e;
|
||||
--md-sys-color-success-container: #42b880;
|
||||
--md-sys-color-on-success-container: #000000;
|
||||
--md-sys-color-warning: #ffe2b1;
|
||||
--md-sys-color-on-warning: #3c2900;
|
||||
--md-sys-color-warning-container: #e4a604;
|
||||
--md-sys-color-on-warning-container: #000000;
|
||||
--md-sys-color-info: #dde5ff;
|
||||
--md-sys-color-on-info: #002a62;
|
||||
--md-sys-color-info-container: #74a2ff;
|
||||
--md-sys-color-on-info-container: #000000;
|
||||
--md-sys-color-inverse-error: #500016;
|
||||
--md-sys-color-inverse-success: #002a18;
|
||||
--md-sys-color-inverse-warning: #312100;
|
||||
--md-sys-color-inverse-info: #002252;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,282 @@
|
||||
"seed": "#6750a4",
|
||||
"variant": "tonal-spot",
|
||||
"spec": "2025",
|
||||
"contrast": 0,
|
||||
"harmonize": false,
|
||||
"contrast": {
|
||||
"standard": 0,
|
||||
"medium": {
|
||||
"light": {
|
||||
"background": "#fdf7fe",
|
||||
"on-background": "#25232b",
|
||||
"surface": "#fdf7fe",
|
||||
"surface-dim": "#ded8e4",
|
||||
"surface-bright": "#fdf7fe",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#f8f1fa",
|
||||
"surface-container": "#f2ecf5",
|
||||
"surface-container-high": "#ece6f0",
|
||||
"surface-container-highest": "#e7e0ec",
|
||||
"on-surface": "#25232b",
|
||||
"on-surface-variant": "#45414b",
|
||||
"outline": "#615d68",
|
||||
"outline-variant": "#7d7983",
|
||||
"inverse-surface": "#0f0d12",
|
||||
"inverse-on-surface": "#c8c3c9",
|
||||
"primary": "#483b6b",
|
||||
"primary-dim": "#3c305f",
|
||||
"on-primary": "#e1d3ff",
|
||||
"primary-container": "#7b6da0",
|
||||
"on-primary-container": "#ffffff",
|
||||
"primary-fixed": "#7b6da0",
|
||||
"primary-fixed-dim": "#6e6093",
|
||||
"on-primary-fixed": "#ffffff",
|
||||
"on-primary-fixed-variant": "#ffffff",
|
||||
"inverse-primary": "#d4c3fd",
|
||||
"secondary": "#464054",
|
||||
"secondary-dim": "#3a3548",
|
||||
"on-secondary": "#dfd5ef",
|
||||
"secondary-container": "#787187",
|
||||
"on-secondary-container": "#ffffff",
|
||||
"secondary-fixed": "#787187",
|
||||
"secondary-fixed-dim": "#6c657b",
|
||||
"on-secondary-fixed": "#ffffff",
|
||||
"on-secondary-fixed-variant": "#ffffff",
|
||||
"tertiary": "#5c3653",
|
||||
"tertiary-dim": "#502b47",
|
||||
"on-tertiary": "#ffcaee",
|
||||
"tertiary-container": "#936787",
|
||||
"on-tertiary-container": "#ffffff",
|
||||
"tertiary-fixed": "#936787",
|
||||
"tertiary-fixed-dim": "#855b7a",
|
||||
"on-tertiary-fixed": "#ffffff",
|
||||
"on-tertiary-fixed-variant": "#ffffff",
|
||||
"error": "#821830",
|
||||
"error-dim": "#6b0221",
|
||||
"on-error": "#ffcdd1",
|
||||
"error-container": "#c44b5f",
|
||||
"on-error-container": "#ffffff",
|
||||
"success": "#004d30",
|
||||
"on-success": "#7df0b3",
|
||||
"success-container": "#008656",
|
||||
"on-success-container": "#ffffff",
|
||||
"warning": "#593e00",
|
||||
"on-warning": "#ffd385",
|
||||
"warning-container": "#986d00",
|
||||
"on-warning-container": "#ffffff",
|
||||
"info": "#003f8e",
|
||||
"on-info": "#cbd9ff",
|
||||
"info-container": "#006fef",
|
||||
"on-info-container": "#ffffff",
|
||||
"inverse-error": "#ff9da8",
|
||||
"inverse-success": "#5ace94",
|
||||
"inverse-warning": "#f3b31e",
|
||||
"inverse-info": "#98b8ff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0f0d12",
|
||||
"on-background": "#ffffff",
|
||||
"surface": "#0f0d12",
|
||||
"surface-dim": "#0f0d12",
|
||||
"surface-bright": "#2e2b34",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#141218",
|
||||
"surface-container": "#1b181f",
|
||||
"surface-container-high": "#211e26",
|
||||
"surface-container-highest": "#27242d",
|
||||
"on-surface": "#ffffff",
|
||||
"on-surface-variant": "#bcb6c2",
|
||||
"outline": "#96919c",
|
||||
"outline-variant": "#78737e",
|
||||
"inverse-surface": "#fdf7fe",
|
||||
"inverse-on-surface": "#39373c",
|
||||
"primary": "#cdc0ec",
|
||||
"primary-dim": "#bfb2de",
|
||||
"on-primary": "#3a3054",
|
||||
"primary-container": "#7a6f96",
|
||||
"on-primary-container": "#ffffff",
|
||||
"primary-fixed": "#ded0fe",
|
||||
"primary-fixed-dim": "#d0c3ef",
|
||||
"on-primary-fixed": "#180e30",
|
||||
"on-primary-fixed-variant": "#3c3256",
|
||||
"inverse-primary": "#5a4f75",
|
||||
"secondary": "#cbc2db",
|
||||
"secondary-dim": "#beb5cd",
|
||||
"on-secondary": "#393347",
|
||||
"secondary-container": "#787187",
|
||||
"on-secondary-container": "#ffffff",
|
||||
"secondary-fixed": "#e8def8",
|
||||
"secondary-fixed-dim": "#dad0ea",
|
||||
"on-secondary-fixed": "#221d2f",
|
||||
"on-secondary-fixed-variant": "#423c50",
|
||||
"tertiary": "#ffcfef",
|
||||
"tertiary-dim": "#f4bfe3",
|
||||
"on-tertiary": "#5e3855",
|
||||
"tertiary-container": "#f4bfe3",
|
||||
"on-tertiary-container": "#542f4c",
|
||||
"tertiary-fixed": "#f4bfe3",
|
||||
"tertiary-fixed-dim": "#e5b2d5",
|
||||
"on-tertiary-fixed": "#180015",
|
||||
"on-tertiary-fixed-variant": "#4a2642",
|
||||
"error": "#ff9da8",
|
||||
"error-dim": "#ff8695",
|
||||
"on-error": "#5f001c",
|
||||
"error-container": "#c44b5f",
|
||||
"on-error-container": "#ffffff",
|
||||
"success": "#5ace94",
|
||||
"on-success": "#00341e",
|
||||
"success-container": "#008656",
|
||||
"on-success-container": "#ffffff",
|
||||
"warning": "#f3b31e",
|
||||
"on-warning": "#412d00",
|
||||
"warning-container": "#e4a604",
|
||||
"on-warning-container": "#332200",
|
||||
"info": "#98b8ff",
|
||||
"on-info": "#002a62",
|
||||
"info-container": "#006fef",
|
||||
"on-info-container": "#ffffff",
|
||||
"inverse-error": "#821830",
|
||||
"inverse-success": "#004d30",
|
||||
"inverse-warning": "#593e00",
|
||||
"inverse-info": "#003f8e"
|
||||
}
|
||||
},
|
||||
"high": {
|
||||
"light": {
|
||||
"background": "#fdf7fe",
|
||||
"on-background": "#000000",
|
||||
"surface": "#fdf7fe",
|
||||
"surface-dim": "#ded8e4",
|
||||
"surface-bright": "#fdf7fe",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#f8f1fa",
|
||||
"surface-container": "#f2ecf5",
|
||||
"surface-container-high": "#ece6f0",
|
||||
"surface-container-highest": "#e7e0ec",
|
||||
"on-surface": "#000000",
|
||||
"on-surface-variant": "#25232b",
|
||||
"outline": "#45414b",
|
||||
"outline-variant": "#54505a",
|
||||
"inverse-surface": "#0f0d12",
|
||||
"inverse-on-surface": "#ffffff",
|
||||
"primary": "#281b49",
|
||||
"primary-dim": "#1e103f",
|
||||
"on-primary": "#e1d3ff",
|
||||
"primary-container": "#584a7b",
|
||||
"on-primary-container": "#ffffff",
|
||||
"primary-fixed": "#584a7b",
|
||||
"primary-fixed-dim": "#4c3f6f",
|
||||
"on-primary-fixed": "#ffffff",
|
||||
"on-primary-fixed-variant": "#ffffff",
|
||||
"inverse-primary": "#d4c3fd",
|
||||
"secondary": "#262134",
|
||||
"secondary-dim": "#1c1729",
|
||||
"on-secondary": "#dfd6ef",
|
||||
"secondary-container": "#554f64",
|
||||
"on-secondary-container": "#ffffff",
|
||||
"secondary-fixed": "#554f64",
|
||||
"secondary-fixed-dim": "#494358",
|
||||
"on-secondary-fixed": "#ffffff",
|
||||
"on-secondary-fixed-variant": "#ffffff",
|
||||
"tertiary": "#391733",
|
||||
"tertiary-dim": "#2e0d28",
|
||||
"on-tertiary": "#ffcbee",
|
||||
"tertiary-container": "#6d4563",
|
||||
"on-tertiary-container": "#ffffff",
|
||||
"tertiary-fixed": "#6d4563",
|
||||
"tertiary-fixed-dim": "#603a57",
|
||||
"on-tertiary-fixed": "#ffffff",
|
||||
"on-tertiary-fixed-variant": "#ffffff",
|
||||
"error": "#500016",
|
||||
"error-dim": "#3d000f",
|
||||
"on-error": "#ffced2",
|
||||
"error-container": "#97283e",
|
||||
"on-error-container": "#ffffff",
|
||||
"success": "#002a18",
|
||||
"on-success": "#7df0b4",
|
||||
"success-container": "#005f3c",
|
||||
"on-success-container": "#ffffff",
|
||||
"warning": "#312100",
|
||||
"on-warning": "#ffd486",
|
||||
"warning-container": "#6c4d00",
|
||||
"on-warning-container": "#ffffff",
|
||||
"info": "#002252",
|
||||
"on-info": "#ccdaff",
|
||||
"info-container": "#004eac",
|
||||
"on-info-container": "#ffffff",
|
||||
"inverse-error": "#ffdddf",
|
||||
"inverse-success": "#89fcbf",
|
||||
"inverse-warning": "#ffe2b1",
|
||||
"inverse-info": "#dde5ff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0f0d12",
|
||||
"on-background": "#ffffff",
|
||||
"surface": "#0f0d12",
|
||||
"surface-dim": "#0f0d12",
|
||||
"surface-bright": "#2e2b34",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#141218",
|
||||
"surface-container": "#1b181f",
|
||||
"surface-container-high": "#211e26",
|
||||
"surface-container-highest": "#27242d",
|
||||
"on-surface": "#ffffff",
|
||||
"on-surface-variant": "#eae3ef",
|
||||
"outline": "#bcb6c2",
|
||||
"outline-variant": "#a7a1ad",
|
||||
"inverse-surface": "#fdf7fe",
|
||||
"inverse-on-surface": "#000000",
|
||||
"primary": "#ebe1ff",
|
||||
"primary-dim": "#ded0fe",
|
||||
"on-primary": "#302649",
|
||||
"primary-container": "#aa9dc8",
|
||||
"on-primary-container": "#000000",
|
||||
"primary-fixed": "#ded0fe",
|
||||
"primary-fixed-dim": "#d0c3ef",
|
||||
"on-primary-fixed": "#000000",
|
||||
"on-primary-fixed-variant": "#180e30",
|
||||
"inverse-primary": "#3c3256",
|
||||
"secondary": "#ebe1fb",
|
||||
"secondary-dim": "#ddd3ed",
|
||||
"on-secondary": "#2e293c",
|
||||
"secondary-container": "#a8a0b8",
|
||||
"on-secondary-container": "#000000",
|
||||
"secondary-fixed": "#e8def8",
|
||||
"secondary-fixed-dim": "#dad0ea",
|
||||
"on-secondary-fixed": "#000000",
|
||||
"on-secondary-fixed-variant": "#221d2f",
|
||||
"tertiary": "#ffdbf2",
|
||||
"tertiary-dim": "#fac5e9",
|
||||
"on-tertiary": "#43203b",
|
||||
"tertiary-container": "#f4bfe3",
|
||||
"on-tertiary-container": "#2e0e29",
|
||||
"tertiary-fixed": "#f4bfe3",
|
||||
"tertiary-fixed-dim": "#e5b2d5",
|
||||
"on-tertiary-fixed": "#000000",
|
||||
"on-tertiary-fixed-variant": "#180015",
|
||||
"error": "#ffdddf",
|
||||
"error-dim": "#ffc7cb",
|
||||
"on-error": "#5f001c",
|
||||
"error-container": "#ff798c",
|
||||
"on-error-container": "#000000",
|
||||
"success": "#89fcbf",
|
||||
"on-success": "#00341e",
|
||||
"success-container": "#42b880",
|
||||
"on-success-container": "#000000",
|
||||
"warning": "#ffe2b1",
|
||||
"on-warning": "#3c2900",
|
||||
"warning-container": "#e4a604",
|
||||
"on-warning-container": "#000000",
|
||||
"info": "#dde5ff",
|
||||
"on-info": "#002a62",
|
||||
"info-container": "#74a2ff",
|
||||
"on-info-container": "#000000",
|
||||
"inverse-error": "#500016",
|
||||
"inverse-success": "#002a18",
|
||||
"inverse-warning": "#312100",
|
||||
"inverse-info": "#002252"
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"background": "#fdf7fe",
|
||||
"on-background": "#34313a",
|
||||
@@ -53,22 +328,22 @@
|
||||
"on-error": "#fff7f7",
|
||||
"error-container": "#f97386",
|
||||
"on-error-container": "#6e0523",
|
||||
"success": "#006c45",
|
||||
"on-success": "#ffffff",
|
||||
"success": "#006d46",
|
||||
"on-success": "#e7ffed",
|
||||
"success-container": "#86f9bc",
|
||||
"on-success-container": "#002112",
|
||||
"warning": "#7c5800",
|
||||
"on-warning": "#ffffff",
|
||||
"warning-container": "#ffdea6",
|
||||
"on-warning-container": "#271900",
|
||||
"info": "#005ac4",
|
||||
"on-info": "#ffffff",
|
||||
"info-container": "#d8e2ff",
|
||||
"on-info-container": "#001a42",
|
||||
"on-success-container": "#00734a",
|
||||
"warning": "#7c5900",
|
||||
"on-warning": "#fff8f1",
|
||||
"warning-container": "#fab925",
|
||||
"on-warning-container": "#6a4b00",
|
||||
"info": "#005bc5",
|
||||
"on-info": "#f9f8ff",
|
||||
"info-container": "#1c7afc",
|
||||
"on-info-container": "#001435",
|
||||
"inverse-error": "#f97386",
|
||||
"inverse-success": "#69dca1",
|
||||
"inverse-warning": "#fdbb28",
|
||||
"inverse-info": "#aec6ff"
|
||||
"inverse-success": "#3bb27b",
|
||||
"inverse-warning": "#f3b31e",
|
||||
"inverse-info": "#699cff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0f0d12",
|
||||
@@ -120,21 +395,21 @@
|
||||
"on-error": "#490013",
|
||||
"error-container": "#871c34",
|
||||
"on-error-container": "#ff97a3",
|
||||
"success": "#69dca1",
|
||||
"on-success": "#003822",
|
||||
"success-container": "#005233",
|
||||
"on-success-container": "#86f9bc",
|
||||
"warning": "#fdbb28",
|
||||
"on-warning": "#412d00",
|
||||
"warning-container": "#5e4200",
|
||||
"on-warning-container": "#ffdea6",
|
||||
"info": "#aec6ff",
|
||||
"on-info": "#002e6a",
|
||||
"info-container": "#004396",
|
||||
"on-info-container": "#d8e2ff",
|
||||
"success": "#3bb27b",
|
||||
"on-success": "#002615",
|
||||
"success-container": "#059460",
|
||||
"on-success-container": "#001e10",
|
||||
"warning": "#f3b31e",
|
||||
"on-warning": "#4e3600",
|
||||
"warning-container": "#e4a604",
|
||||
"on-warning-container": "#593f00",
|
||||
"info": "#699cff",
|
||||
"on-info": "#001e4b",
|
||||
"info-container": "#0c74f6",
|
||||
"on-info-container": "#000a23",
|
||||
"inverse-error": "#a8364b",
|
||||
"inverse-success": "#006c45",
|
||||
"inverse-warning": "#7c5800",
|
||||
"inverse-info": "#005ac4"
|
||||
"inverse-success": "#006d46",
|
||||
"inverse-warning": "#7c5900",
|
||||
"inverse-info": "#005bc5"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+112
-19
@@ -19,7 +19,8 @@ class SchemeCommand extends Command
|
||||
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
||||
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
||||
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
|
||||
{--contrast=0 : The contrast level, from -1 to 1}
|
||||
{--contrast=0 : The standard level\'s contrast, from -1 to below 0.5; medium and high are always generated}
|
||||
{--harmonize : Pull the success, warning and info colours towards the seed}
|
||||
{--success=#22a06b : The source of the success colour}
|
||||
{--warning=#e2a400 : The source of the warning colour}
|
||||
{--info=#1d7afc : The source of the info colour}
|
||||
@@ -38,6 +39,16 @@ class SchemeCommand extends Command
|
||||
*/
|
||||
protected const SPECS = ['2021', '2025'];
|
||||
|
||||
/**
|
||||
* M3's other two contrast levels, generated for every scheme beside the standard one and
|
||||
* keyed on <html data-contrast> (styles/color/roles, "What's new May 2025"): medium is
|
||||
* 3:1 on the roles that carry text, high 7:1. Their levels are Google's, not the
|
||||
* installation's — `--contrast` moves the standard block alone.
|
||||
*
|
||||
* @var array<string, float>
|
||||
*/
|
||||
protected const LEVELS = ['medium' => 0.5, 'high' => 1.0];
|
||||
|
||||
public function handle(Filesystem $files): int
|
||||
{
|
||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||
@@ -51,11 +62,16 @@ class SchemeCommand extends Command
|
||||
}
|
||||
|
||||
if (filled($this->argument('seed'))) {
|
||||
if ($this->refusesContrast((float) $this->option('contrast'))) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$input = [
|
||||
'seed' => (string) $this->argument('seed'),
|
||||
'variant' => (string) $this->option('variant'),
|
||||
'spec' => $spec,
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'harmonize' => (bool) $this->option('harmonize'),
|
||||
'success' => (string) $this->option('success'),
|
||||
'warning' => (string) $this->option('warning'),
|
||||
'info' => (string) $this->option('info'),
|
||||
@@ -87,12 +103,17 @@ class SchemeCommand extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->refusesContrast((float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ")) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// A profile's own spec and state colours win; without them, the command's options apply.
|
||||
$scheme = $this->generate([
|
||||
'seed' => (string) ($profile['seed'] ?? ''),
|
||||
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
|
||||
'spec' => (string) ($profile['spec'] ?? $spec),
|
||||
'contrast' => (float) ($profile['contrast'] ?? 0),
|
||||
'harmonize' => (bool) ($profile['harmonize'] ?? $this->option('harmonize')),
|
||||
'success' => (string) ($profile['success'] ?? $this->option('success')),
|
||||
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
|
||||
'info' => (string) ($profile['info'] ?? $this->option('info')),
|
||||
@@ -115,11 +136,33 @@ class SchemeCommand extends Command
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given standard contrast level is one this command generates, having said why
|
||||
* it is not. Medium and high are M3's own levels and always generated; `--contrast` moves
|
||||
* the standard block, which has to stay below them.
|
||||
*/
|
||||
protected function refusesContrast(float $contrast, string $context = ''): bool
|
||||
{
|
||||
if ($contrast >= self::LEVELS['medium'] || $contrast < -1) {
|
||||
$this->components->error($context.sprintf(
|
||||
'The contrast level %s is the standard block\'s, from -1 to below %s. Medium (%s) and high (%s) are always generated beside it, under [data-contrast]; the head script picks one.',
|
||||
$contrast,
|
||||
self::LEVELS['medium'],
|
||||
self::LEVELS['medium'],
|
||||
self::LEVELS['high'],
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
||||
*
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
|
||||
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
|
||||
* @return array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array{standard: float, medium: array{light: array<string, string>, dark: array<string, string>}, high: array{light: array<string, string>, dark: array<string, string>}}, light: array<string, string>, dark: array<string, string>}|null
|
||||
*/
|
||||
protected function generate(array $input, string $context = ''): ?array
|
||||
{
|
||||
@@ -162,8 +205,8 @@ class SchemeCommand extends Command
|
||||
* The stylesheet, headed by the command that regenerates it: every option that differs from
|
||||
* its default is written out.
|
||||
*
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
|
||||
* @param array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input
|
||||
*/
|
||||
protected function stylesheet(array $scheme, array $input): string
|
||||
{
|
||||
@@ -173,15 +216,16 @@ class SchemeCommand extends Command
|
||||
->implode('');
|
||||
|
||||
$command = sprintf(
|
||||
'php artisan material:scheme "%s" --variant=%s%s%s%s',
|
||||
'php artisan material:scheme "%s" --variant=%s%s%s%s%s',
|
||||
$scheme['seed'],
|
||||
$scheme['variant'],
|
||||
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||
$scheme['contrast']['standard'] != 0 ? ' --contrast='.$scheme['contrast']['standard'] : '',
|
||||
$scheme['harmonize'] ? ' --harmonize' : '',
|
||||
$states,
|
||||
);
|
||||
|
||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||
$blocks = $this->levels($scheme);
|
||||
|
||||
return <<<CSS
|
||||
/*
|
||||
@@ -192,6 +236,11 @@ class SchemeCommand extends Command
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme before the
|
||||
* first paint; the light block also stands without it.
|
||||
*
|
||||
* M3's medium and high contrast levels follow the standard blocks, keyed on
|
||||
* data-contrast — which the head script writes from the visitor's choice, or from the
|
||||
* operating system's contrast setting while that choice is `system`. No media query
|
||||
* decides here, as none decides the theme.
|
||||
*/
|
||||
|
||||
{$blocks}
|
||||
@@ -205,18 +254,19 @@ class SchemeCommand extends Command
|
||||
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
||||
* page's profile.
|
||||
*
|
||||
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||
* @param array<string, array{label: string, seed: string, variant: string, spec: string, harmonize: bool, contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||
*/
|
||||
protected function profilesStylesheet(array $profiles, string $default): string
|
||||
{
|
||||
$list = collect($profiles)
|
||||
->map(fn (array $profile, string $name): string => sprintf(
|
||||
' * %-12s %s, %s%s%s',
|
||||
' * %-12s %s, %s%s%s%s',
|
||||
$name,
|
||||
$profile['seed'],
|
||||
$profile['variant'],
|
||||
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
|
||||
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
||||
$profile['contrast']['standard'] != 0 ? ', contrast '.$profile['contrast']['standard'] : '',
|
||||
$profile['harmonize'] ? ', harmonized' : '',
|
||||
))
|
||||
->implode("\n");
|
||||
|
||||
@@ -232,25 +282,68 @@ class SchemeCommand extends Command
|
||||
{$list}
|
||||
*
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
|
||||
* before the first paint.
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme, data-scheme
|
||||
* and data-contrast before the first paint.
|
||||
*/
|
||||
|
||||
CSS;
|
||||
|
||||
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
|
||||
$css .= "\n".$this->levels($profiles[$default]);
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
$css .= "\n".$this->blocks(
|
||||
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
|
||||
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
|
||||
$profile,
|
||||
);
|
||||
$css .= "\n".$this->levels($profile, "[data-scheme='{$name}']");
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scheme, whole: the standard level first, then M3's medium and high under
|
||||
* data-contrast. A level's blocks are the standard ones with one more attribute, so they
|
||||
* outrank them wherever both match, and the light blocks of a level come before its dark
|
||||
* ones as in every other pair here. The nested `[data-contrast='high'] [data-theme='light']`
|
||||
* form keeps a light panel inside a high-contrast dark page on the level the page asked for
|
||||
* — the plain `[data-theme='light']` block would otherwise take it back to standard.
|
||||
*
|
||||
* @param array{contrast: array<string, mixed>, light: array<string, string>, dark: array<string, string>} $scheme
|
||||
*/
|
||||
protected function levels(array $scheme, string $prefix = ''): string
|
||||
{
|
||||
[$light, $dark] = $this->selectors($prefix);
|
||||
|
||||
$css = $this->blocks($light, $dark, $scheme);
|
||||
|
||||
foreach (array_keys(self::LEVELS) as $level) {
|
||||
[$light, $dark] = $this->selectors($prefix."[data-contrast='{$level}']");
|
||||
|
||||
/** @var array{light: array<string, string>, dark: array<string, string>} $roles */
|
||||
$roles = $scheme['contrast'][$level];
|
||||
|
||||
$css .= "\n".$this->blocks($light, $dark, $roles);
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* The light and the dark selectors of one block, under the given prefix. Without a prefix
|
||||
* the plain blocks stand on `:root` and on the attribute alone; with one, the prefix is on
|
||||
* <html> and the theme may be on <html> or on a panel inside it, so both forms are written.
|
||||
*
|
||||
* @return array{list<string>, list<string>}
|
||||
*/
|
||||
protected function selectors(string $prefix): array
|
||||
{
|
||||
if ($prefix === '') {
|
||||
return [[':root', "[data-theme='light']"], ["[data-theme='dark']"]];
|
||||
}
|
||||
|
||||
return [
|
||||
[$prefix, "{$prefix}[data-theme='light']", "{$prefix} [data-theme='light']"],
|
||||
["{$prefix}[data-theme='dark']", "{$prefix} [data-theme='dark']"],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A light and a dark block of roles under the given selectors.
|
||||
*
|
||||
|
||||
+50
-21
@@ -18,6 +18,11 @@ use Throwable;
|
||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
||||
* refused, so nothing but a colour ever reaches a stylesheet.
|
||||
*
|
||||
* The roles at the top level are the standard contrast level; M3's medium and high levels sit
|
||||
* under `contrast` and are asked for by name (`load($path, $profile, 'high')`). A file written
|
||||
* before the levels existed — or one whose level is missing a role — falls back to the standard
|
||||
* roles, so a mail or an error page never draws a hole.
|
||||
*
|
||||
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
|
||||
* default under `default`, and the default's roles at the top level as a single scheme does. Which
|
||||
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
|
||||
@@ -30,6 +35,14 @@ use Throwable;
|
||||
*/
|
||||
class Scheme
|
||||
{
|
||||
/**
|
||||
* M3's contrast levels, as `material:scheme` writes them and as <html data-contrast> names
|
||||
* them. `standard` is the scheme's own level and has no attribute.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const LEVELS = ['standard', 'medium', 'high'];
|
||||
|
||||
/**
|
||||
* @var (Closure(): ?string)|null
|
||||
*/
|
||||
@@ -46,14 +59,15 @@ class Scheme
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
|
||||
* The roles to draw: the given profile's, the active profile's, or the single scheme's, at
|
||||
* the given contrast level (`standard`, `medium` or `high`; anything else is standard).
|
||||
*
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
public static function load(?string $path = null, ?string $profile = null): array
|
||||
public static function load(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
|
||||
{
|
||||
$data = static::data($path);
|
||||
$profiles = static::profilesFrom($data);
|
||||
$profiles = static::profilesFrom($data, $contrast);
|
||||
|
||||
if ($profiles !== []) {
|
||||
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
|
||||
@@ -61,13 +75,7 @@ class Scheme
|
||||
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
|
||||
}
|
||||
|
||||
$default = static::defaultScheme();
|
||||
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||
|
||||
return [
|
||||
'light' => [...$default['light'], ...$scheme['light']],
|
||||
'dark' => [...$default['dark'], ...$scheme['dark']],
|
||||
];
|
||||
return static::scheme($data, $contrast);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,9 +83,9 @@ class Scheme
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function light(?string $path = null, ?string $profile = null): array
|
||||
public static function light(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
|
||||
{
|
||||
return static::load($path, $profile)['light'];
|
||||
return static::load($path, $profile, $contrast)['light'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,9 +93,9 @@ class Scheme
|
||||
*
|
||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||
*/
|
||||
public static function profiles(?string $path = null): array
|
||||
public static function profiles(?string $path = null, string $contrast = 'standard'): array
|
||||
{
|
||||
return static::profilesFrom(static::data($path));
|
||||
return static::profilesFrom(static::data($path), $contrast);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,13 +124,12 @@ class Scheme
|
||||
* @param array<mixed> $data
|
||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||
*/
|
||||
protected static function profilesFrom(array $data): array
|
||||
protected static function profilesFrom(array $data, string $contrast = 'standard'): array
|
||||
{
|
||||
if (! is_array($data['profiles'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$default = static::defaultScheme();
|
||||
$profiles = [];
|
||||
|
||||
foreach ($data['profiles'] as $name => $profile) {
|
||||
@@ -132,14 +139,32 @@ class Scheme
|
||||
|
||||
$profiles[$name] = [
|
||||
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
||||
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
|
||||
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
|
||||
...static::scheme($profile, $contrast),
|
||||
];
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scheme's roles at one contrast level: the level's own, over the standard ones it does
|
||||
* not name, over the package's default. A scheme file written before the levels existed has
|
||||
* only the standard roles, and draws them at every level rather than nothing.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
protected static function scheme(array $data, string $contrast): array
|
||||
{
|
||||
$default = static::defaultScheme($contrast);
|
||||
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
|
||||
|
||||
return [
|
||||
'light' => [...$default['light'], ...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
|
||||
'dark' => [...$default['dark'], ...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @param non-empty-array<string, mixed> $profiles
|
||||
@@ -162,15 +187,19 @@ class Scheme
|
||||
}
|
||||
|
||||
/**
|
||||
* The package's own scheme, which fills any role a file lacks.
|
||||
* The package's own scheme at one contrast level, which fills any role a file lacks.
|
||||
*
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
protected static function defaultScheme(): array
|
||||
protected static function defaultScheme(string $contrast = 'standard'): array
|
||||
{
|
||||
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
|
||||
$level = is_array($data['contrast'][$contrast] ?? null) ? $data['contrast'][$contrast] : [];
|
||||
|
||||
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||
return [
|
||||
'light' => [...static::roles($data['light'] ?? null), ...static::roles($level['light'] ?? null)],
|
||||
'dark' => [...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,37 @@
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The hue of a #rrggbb colour, in degrees: what harmonisation moves.
|
||||
*/
|
||||
function schemeHue(string $hex): float
|
||||
{
|
||||
[$r, $g, $b] = array_map(fn (string $channel): float => hexdec($channel) / 255, str_split(ltrim($hex, '#'), 2));
|
||||
$chroma = max($r, $g, $b) - min($r, $g, $b);
|
||||
|
||||
if ($chroma === 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$sector = match (max($r, $g, $b)) {
|
||||
$r => fmod(($g - $b) / $chroma, 6),
|
||||
$g => (($b - $r) / $chroma) + 2,
|
||||
default => (($r - $g) / $chroma) + 4,
|
||||
};
|
||||
|
||||
return fmod(($sector * 60) + 360, 360);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far apart two colours are on the hue circle, the short way round.
|
||||
*/
|
||||
function schemeHueDistance(string $one, string $other): float
|
||||
{
|
||||
$apart = abs(schemeHue($one) - schemeHue($other));
|
||||
|
||||
return min($apart, 360 - $apart);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
|
||||
$this->data = Str::replaceLast('.css', '.json', $this->stylesheet);
|
||||
@@ -23,6 +54,8 @@ it('writes the scheme as a stylesheet and as data', function () {
|
||||
->seed->toBe('#4f46e5')
|
||||
->variant->toBe('tonal-spot')
|
||||
->spec->toBe('2025')
|
||||
->harmonize->toBeFalse()
|
||||
->and($scheme['contrast']['standard'])->toBe(0)
|
||||
->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light']))
|
||||
->and($scheme['light'])->toHaveKeys(['primary', 'on-primary', 'tertiary-container', 'surface-container-high', 'outline-variant', 'success', 'on-warning-container', 'info-container'])
|
||||
->and($scheme['light']['surface'])->not->toBe($scheme['dark']['surface'])
|
||||
@@ -41,6 +74,137 @@ it('writes the scheme as a stylesheet and as data', function () {
|
||||
expect($stylesheet)->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot');
|
||||
});
|
||||
|
||||
it('writes M3\'s three contrast levels for both themes, keyed on data-contrast', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
|
||||
->assertSuccessful();
|
||||
|
||||
$scheme = json_decode(File::get($this->data), true);
|
||||
$stylesheet = File::get($this->stylesheet);
|
||||
|
||||
// The standard level keeps the 1.x shape at the top; the other two sit under `contrast`.
|
||||
expect(array_keys($scheme['contrast']))->toBe(['standard', 'medium', 'high'])
|
||||
->and(array_keys($scheme['contrast']['medium']['light']))->toEqual(array_keys($scheme['light']))
|
||||
->and(array_keys($scheme['contrast']['high']['dark']))->toEqual(array_keys($scheme['dark']));
|
||||
|
||||
foreach (['medium', 'high'] as $level) {
|
||||
foreach (['light' => "[data-contrast='{$level}'] [data-theme='light'] {", 'dark' => "[data-contrast='{$level}'] [data-theme='dark'] {"] as $theme => $selector) {
|
||||
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
|
||||
|
||||
expect($block)->toContain("color-scheme: {$theme};");
|
||||
|
||||
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
|
||||
expect($block)->toContain("--md-sys-color-{$role}: {$hex};");
|
||||
}
|
||||
}
|
||||
|
||||
expect($stylesheet)
|
||||
->toContain("[data-contrast='{$level}'],\n[data-contrast='{$level}'][data-theme='light'],")
|
||||
->toContain("[data-contrast='{$level}'][data-theme='dark'],");
|
||||
}
|
||||
|
||||
// Every level differs from the one below it, in both themes and for the state colours too.
|
||||
foreach (['light', 'dark'] as $theme) {
|
||||
foreach (['on-surface-variant', 'outline', 'success', 'info', 'success-container'] as $role) {
|
||||
expect($scheme['contrast']['medium'][$theme][$role])
|
||||
->not->toBe($scheme[$theme][$role], "medium {$theme} {$role}")
|
||||
->not->toBe($scheme['contrast']['high'][$theme][$role], "high {$theme} {$role}");
|
||||
}
|
||||
|
||||
// The state colours are dynamic colours now, so the level reaches them as it does a
|
||||
// built-in role. (A container whose tone already clears the level's ratio stays put,
|
||||
// exactly as error's does — hence the pairs rather than every one of the four.)
|
||||
foreach (['success', 'warning', 'info'] as $state) {
|
||||
foreach ([$state, "on-{$state}", "on-{$state}-container"] as $role) {
|
||||
expect($scheme['contrast']['high'][$theme][$role])->not->toBe($scheme[$theme][$role], "high {$theme} {$role}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($stylesheet)->not->toContain('prefers-contrast');
|
||||
});
|
||||
|
||||
it('puts a level\'s nested light blocks after the dark ones of the level below', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
|
||||
->assertSuccessful();
|
||||
|
||||
$stylesheet = File::get($this->stylesheet);
|
||||
$at = fn (string $selector): int => strpos($stylesheet, $selector);
|
||||
|
||||
// A light panel inside a dark high-contrast page: its rule carries one attribute more than
|
||||
// the plain dark block and stands after it, so it wins on specificity and on order.
|
||||
expect($at("[data-theme='dark'] {"))->toBeLessThan($at("[data-contrast='high'] [data-theme='light'] {"))
|
||||
->and($at("[data-contrast='medium'] [data-theme='light'] {"))->toBeLessThan($at("[data-contrast='medium'][data-theme='dark'],"))
|
||||
->and($at("[data-contrast='medium'][data-theme='dark'],"))->toBeLessThan($at("[data-contrast='high'],"));
|
||||
});
|
||||
|
||||
it('generates the levels for every profile, under its own data-scheme', function () {
|
||||
config(['livewire-material.profiles' => [
|
||||
'indigo' => ['seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
]]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
|
||||
|
||||
$scheme = json_decode(File::get($this->data), true);
|
||||
$stylesheet = File::get($this->stylesheet);
|
||||
|
||||
foreach ($scheme['profiles'] as $name => $profile) {
|
||||
foreach (['medium', 'high'] as $level) {
|
||||
$selector = "[data-scheme='{$name}'][data-contrast='{$level}'] [data-theme='dark'] {";
|
||||
|
||||
expect(Str::of($stylesheet)->after($selector)->before('}')->toString())
|
||||
->toContain("--md-sys-color-primary: {$profile['contrast'][$level]['dark']['primary']};")
|
||||
->and($profile['contrast'][$level]['light']['success'])->not->toBe($profile['light']['success']);
|
||||
}
|
||||
}
|
||||
|
||||
// The default profile's plain blocks come first, its levels next, then every profile's.
|
||||
expect(strpos($stylesheet, "[data-contrast='high'],"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
|
||||
});
|
||||
|
||||
it('refuses a standard contrast level at or above the medium one', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.5', '--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('Medium (0.5) and high (1) are always generated')
|
||||
->assertFailed();
|
||||
|
||||
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||
|
||||
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'contrast' => 1]]]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('Profile "indigo": The contrast level 1')
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
it('keeps a standard level below medium, and records it in the header', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--contrast' => '0.3', '--output' => $this->stylesheet])
|
||||
->assertSuccessful();
|
||||
|
||||
$scheme = json_decode(File::get($this->data), true);
|
||||
|
||||
expect($scheme['contrast']['standard'])->toBe(0.3)
|
||||
->and($scheme['light']['primary'])->not->toBe($scheme['contrast']['medium']['light']['primary'])
|
||||
->and(File::get($this->stylesheet))->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot --contrast=0.3');
|
||||
});
|
||||
|
||||
it('harmonises the state colours towards the seed only when asked', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])->assertSuccessful();
|
||||
$plain = json_decode(File::get($this->data), true);
|
||||
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--harmonize' => true, '--output' => $this->stylesheet])->assertSuccessful();
|
||||
$harmonized = json_decode(File::get($this->data), true);
|
||||
|
||||
expect($harmonized['harmonize'])->toBeTrue()
|
||||
->and($plain['harmonize'])->toBeFalse()
|
||||
->and(File::get($this->stylesheet))->toContain('--variant=tonal-spot --harmonize');
|
||||
|
||||
foreach (['success', 'warning', 'info'] as $state) {
|
||||
expect(schemeHueDistance($harmonized['light'][$state], '#4f46e5'))
|
||||
->toBeLessThan(schemeHueDistance($plain['light'][$state], '#4f46e5'), "{$state} moved towards the seed")
|
||||
->and($harmonized['contrast']['high']['dark'][$state])->not->toBe($plain['contrast']['high']['dark'][$state]);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates a different scheme per variant', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
|
||||
->assertSuccessful();
|
||||
@@ -177,7 +341,8 @@ it('takes a profile\'s own spec and state colours, and the command\'s for a prof
|
||||
->and($profiles['expressive'])->spec->toBe('2025')
|
||||
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
|
||||
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
|
||||
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
|
||||
// The state colours follow the spec now, so the same source is not the same colour.
|
||||
->and($profiles['expressive']['dark']['info'])->not->toBe('#80cfff')
|
||||
->and(File::get($this->stylesheet))
|
||||
->toContain('(spec 2025)')
|
||||
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
|
||||
|
||||
@@ -19,6 +19,11 @@ function writeProfiles(string $path, array $primaries, string $default): void
|
||||
'contrast' => 0,
|
||||
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
|
||||
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
|
||||
'contrast' => [
|
||||
'standard' => 0,
|
||||
'medium' => ['light' => ['primary' => '#332f8a'], 'dark' => ['surface' => '#0a0a0a']],
|
||||
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff', 'surface' => '#000000']],
|
||||
],
|
||||
])->all();
|
||||
|
||||
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
|
||||
@@ -69,6 +74,29 @@ it('loads a profile by name, whatever is active', function () {
|
||||
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
|
||||
});
|
||||
|
||||
it('loads a contrast level, falling back to the standard roles it does not name', function () {
|
||||
expect(Scheme::load($this->path, 'teal', 'high')['light']['primary'])->toBe('#000000')
|
||||
->and(Scheme::load($this->path, 'teal', 'high')['dark']['surface'])->toBe('#000000')
|
||||
// The level names no surface in light, so the standard one stands.
|
||||
->and(Scheme::light($this->path, 'teal', 'high')['surface'])->toBe('#fafafa')
|
||||
->and(Scheme::load($this->path, 'teal', 'medium')['light']['primary'])->toBe('#332f8a')
|
||||
->and(Scheme::load($this->path, 'teal')['light']['primary'])->toBe('#00897b')
|
||||
// A level nobody generated, and a name that is not one, are the standard scheme.
|
||||
->and(Scheme::load($this->path, 'teal', 'extreme')['light']['primary'])->toBe('#00897b')
|
||||
->and(Scheme::profiles($this->path, 'high')['rose']['light']['primary'])->toBe('#000000')
|
||||
->and(Scheme::profiles($this->path)['rose']['light']['primary'])->toBe('#c2185b');
|
||||
});
|
||||
|
||||
it('draws a high contrast level from the package default for a scheme file without one', function () {
|
||||
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||
|
||||
$package = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
|
||||
|
||||
expect(Scheme::light($this->path, null, 'high')['primary'])->toBe('#123456')
|
||||
->and(Scheme::light($this->path, null, 'high')['surface'])->toBe($package['contrast']['high']['light']['surface'])
|
||||
->and(Scheme::light($this->path)['surface'])->toBe($package['light']['surface']);
|
||||
});
|
||||
|
||||
it('has no profile for a single scheme, and loads it as before', function () {
|
||||
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
|
||||
@@ -38,6 +38,23 @@ it('ships a default scheme that declares every role in both themes', function ()
|
||||
}
|
||||
});
|
||||
|
||||
it('ships M3\'s medium and high contrast levels beside the standard one', function () {
|
||||
$scheme = json_decode(File::get(packageCss('tokens/scheme.json')), true);
|
||||
$css = File::get(packageCss('tokens/scheme.css'));
|
||||
|
||||
foreach (['medium', 'high'] as $level) {
|
||||
foreach (['light', 'dark'] as $theme) {
|
||||
$declared = declarations($css, "[data-contrast='{$level}'] [data-theme='{$theme}'] {");
|
||||
|
||||
expect(array_keys($declared))->toEqual(array_map(fn (string $role): string => "--md-sys-color-{$role}", array_keys($scheme['light'])));
|
||||
|
||||
foreach ($scheme['contrast'][$level][$theme] as $role => $hex) {
|
||||
expect($declared["--md-sys-color-{$role}"])->toBe($hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('turns every role into a Tailwind colour', function () {
|
||||
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
|
||||
$theme = File::get(packageCss('tokens/theme.css'));
|
||||
@@ -58,9 +75,11 @@ it('resolves colour utilities on the element, so a nested data-theme repaints th
|
||||
->not->toMatch('/--[\w-]+:\s*#/');
|
||||
});
|
||||
|
||||
it('leaves the choice of theme to the head script, never to a media query', function () {
|
||||
it('leaves the choice of theme and of contrast to the head script, never to a media query', function () {
|
||||
foreach (File::allFiles(packageCss()) as $file) {
|
||||
expect($file->getContents())->not->toContain('prefers-color-scheme');
|
||||
expect($file->getContents())
|
||||
->not->toContain('prefers-color-scheme')
|
||||
->not->toContain('prefers-contrast');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user