['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
];
+ // The roles a contrast level moves most: the page keeps its surface, the ink and the
+ // outlines carry the level.
+ $levels = ['standard' => 'Standard', 'medium' => 'Medium · 3:1', 'high' => 'High · 7:1'];
+ $levelRoles = ['bg-primary', 'bg-on-primary', 'bg-primary-container', 'bg-on-primary-container', 'bg-on-surface-variant', 'bg-outline', 'bg-error', 'bg-success', 'bg-warning', 'bg-info'];
+ $profile = \NoNameWeb\LivewireMaterial\Support\Scheme::profile();
+
$examples = [
'Colour profiles' => <<<'BLADE'
+ BLADE,
];
@endphp
@@ -34,10 +43,43 @@
profiles the picker draws nothing.
+
+ 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 <html data-contrast>, following the operating system
+ until they choose. The page keeps its surface; the ink, the outlines and the containers carry the level.
+
@foreach (['light', 'dark'] as $theme)
diff --git a/src/Console/SchemeCommand.php b/src/Console/SchemeCommand.php
index 01ed43ca..0b89213a 100644
--- a/src/Console/SchemeCommand.php
+++ b/src/Console/SchemeCommand.php
@@ -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 (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
+ */
+ 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, dark: array}|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, dark: array}, high: array{light: array, dark: array}}, light: array, dark: array}|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, dark: array} $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, light: array, dark: array} $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 <<, dark: array}> $profiles
+ * @param array, light: array, dark: array}> $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, light: array, dark: array} $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, dark: array} $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
+ * and the theme may be on or on a panel inside it, so both forms are written.
+ *
+ * @return array{list, list}
+ */
+ 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.
*
diff --git a/src/Support/Scheme.php b/src/Support/Scheme.php
index a4fdc900..7a47237e 100644
--- a/src/Support/Scheme.php
+++ b/src/Support/Scheme.php
@@ -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 names
+ * them. `standard` is the scheme's own level and has no attribute.
+ *
+ * @var list
+ */
+ 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, dark: array}
*/
- 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
*/
- 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, dark: array}>
*/
- 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 $data
* @return array, dark: array}>
*/
- 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 $data
+ * @return array{light: array, dark: array}
+ */
+ 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 $data
* @param non-empty-array $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, dark: array}
*/
- 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)],
+ ];
}
/**
diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php
index f14191c3..db85e50b 100644
--- a/tests/Feature/Components/AppBarTest.php
+++ b/tests/Feature/Components/AppBarTest.php
@@ -69,6 +69,19 @@ it('draws floating and docked toolbars', function () {
->not->toContain('data-toolbar-group');
});
+it('offers M3\'s three contrast levels, marking the one in force', function () {
+ expect((string) $this->blade(''))
+ ->toContain('data-theme-toggle="contrast"')
+ ->toContain('role="radiogroup"')
+ ->toContain('aria-label="Contrast"')
+ ->toContain('data-contrast-option="standard"')
+ ->toContain('data-contrast-option="medium"')
+ ->toContain('data-contrast-option="high"')
+ ->toContain("\$store.theme.setContrast('high')")
+ // The row follows the resolved level, so the operating system's shows under `system`.
+ ->toContain("(\$store.theme.resolvedContrast === 'medium').toString()");
+});
+
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade(''))
->toContain('data-theme-toggle="toggle"')
diff --git a/tests/Feature/Components/SchemePickerTest.php b/tests/Feature/Components/SchemePickerTest.php
index 7b770401..3f548709 100644
--- a/tests/Feature/Components/SchemePickerTest.php
+++ b/tests/Feature/Components/SchemePickerTest.php
@@ -35,11 +35,37 @@ it('draws a radio per generated profile, bound, named and previewing on change',
it('puts its other attributes on the group and the binding on the radios', function () {
$html = (string) $this->blade('');
- expect($html)->toMatch('/