Extract the scheme stylesheet shape for ErrorPage to reuse

SchemeCommand::levels()/selectors()/blocks() move to a new
SchemeStylesheet, and Scheme gains forStylesheet(), which resolves a
scheme (and every profile) at all three contrast levels together. Plan
step 40's fallback needs to draw the same colours material:scheme
writes, without duplicating its selector logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 07:29:50 +02:00
co-authored by Claude Sonnet 5
parent 513e344912
commit 3b9a889e7c
3 changed files with 158 additions and 67 deletions
+4 -67
View File
@@ -7,6 +7,7 @@ use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use JsonException;
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'material:scheme')]
@@ -225,7 +226,7 @@ class SchemeCommand extends Command
$states,
);
$blocks = $this->levels($scheme);
$blocks = SchemeStylesheet::levels($scheme);
return <<<CSS
/*
@@ -288,76 +289,12 @@ class SchemeCommand extends Command
CSS;
$css .= "\n".$this->levels($profiles[$default]);
$css .= "\n".SchemeStylesheet::levels($profiles[$default]);
foreach ($profiles as $name => $profile) {
$css .= "\n".$this->levels($profile, "[data-scheme='{$name}']");
$css .= "\n".SchemeStylesheet::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.
*
* @param list<string> $light
* @param list<string> $dark
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
*/
protected function blocks(array $light, array $dark, array $scheme): string
{
$roles = fn (array $roles): string => collect($roles)
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
->implode("\n");
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
}
}
+67
View File
@@ -109,6 +109,52 @@ class Scheme
return $profiles === [] ? null : static::activeFrom($data, $profiles);
}
/**
* The scheme, and every profile once there is more than one, in the shape
* `SchemeStylesheet::levels()` draws: light and dark roles at all three contrast levels
* together, filled from the package's own default wherever the file or a profile is missing a
* role — the same fallback `load()` gives one level at a time, given here for all three at
* once, since a stylesheet has to carry every level in one pass.
*
* Which profile is *active* is left to the browser: by the time a page reads this, the theme
* script has already written the resolved one to `<html data-scheme>` (`profile()`, embedded
* server-side), so every profile is returned here, keyed by name, for `[data-scheme]` to select
* between — `scheme` doubles as the plain, attribute-less blocks (`:root`, `[data-theme]`),
* exactly as the file `material:scheme` generates gives its own default profile's roles twice.
* `ErrorPage::fallbackStyles()`'s only path to a scheme: there is no build to serve a generated
* `material-scheme.css`, so this draws the same colours from the same JSON instead.
*
* @return array{scheme: array{light: array<string, string>, dark: array<string, string>, contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>}, profiles: array<string, array{label: string, light: array<string, string>, dark: array<string, string>, contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>}>}
*/
public static function forStylesheet(?string $path = null): array
{
$data = static::data($path);
$profiles = [];
if (is_array($data['profiles'] ?? null)) {
foreach ($data['profiles'] as $name => $profile) {
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
continue;
}
$profiles[$name] = [
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
...static::fullScheme($profile),
];
}
}
if ($profiles === []) {
return ['scheme' => static::fullScheme($data), 'profiles' => []];
}
$default = is_string($data['default'] ?? null) && isset($profiles[$data['default']])
? $data['default']
: (string) array_key_first($profiles);
return ['scheme' => $profiles[$default], 'profiles' => $profiles];
}
/**
* @return array<mixed>
*/
@@ -165,6 +211,27 @@ class Scheme
];
}
/**
* One scheme (a single scheme's data, or one profile's) at every contrast level together, in
* the shape `SchemeStylesheet::levels()` reads: `scheme()` already merges a level's own roles
* over the standard ones it does not name, over the package's default, one level at a time —
* this asks it for all three and nests medium and high under `contrast`, since a stylesheet's
* blocks for one scheme need every level in a single call.
*
* @param array<mixed> $data
* @return array{light: array<string, string>, dark: array<string, string>, contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>}
*/
protected static function fullScheme(array $data): array
{
return [
...static::scheme($data, 'standard'),
'contrast' => [
'medium' => static::scheme($data, 'medium'),
'high' => static::scheme($data, 'high'),
],
];
}
/**
* @param array<mixed> $data
* @param non-empty-array<string, mixed> $profiles
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace NoNameWeb\LivewireMaterial\Support;
/**
* The selector shape a Material 3 colour scheme is written in: the standard level on `:root` and
* `[data-theme]` (or, under a prefix, repeated so it still reaches a theme set on `<html>` or on a
* nested panel), the medium and high contrast levels under `[data-contrast]` beside it, and — when
* a caller gives one — every colour profile again under its own prefix (`[data-scheme='name']`).
*
* `SchemeCommand::handle()` writes this to the application's `material-scheme.css`; plan step 40
* moved the shape itself here so `ErrorPage::fallbackStyles()` can draw the same scheme, from
* `Scheme::forStylesheet()` rather than a generated file, without a second copy of the selectors to
* keep in step with the command's.
*/
final class SchemeStylesheet
{
/**
* M3's two contrast levels beyond standard, keyed on `<html data-contrast>`; standard is the
* scheme's own level and has no attribute of its own.
*
* @var list<string>
*/
public const array LEVELS = ['medium', 'high'];
/**
* One scheme, whole: the standard level first, under `$prefix` (or `:root`/`[data-theme]`
* without one), then M3's medium and high levels under `[data-contrast]` beside it. 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.
*
* @param array{contrast: array<string, array{light: array<string, string>, dark: array<string, string>}>, light: array<string, string>, dark: array<string, string>} $scheme
*/
public static function levels(array $scheme, string $prefix = ''): string
{
[$light, $dark] = self::selectors($prefix);
$css = self::blocks($light, $dark, $scheme);
foreach (self::LEVELS as $level) {
[$light, $dark] = self::selectors($prefix."[data-contrast='{$level}']");
/** @var array{light: array<string, string>, dark: array<string, string>} $roles */
$roles = $scheme['contrast'][$level];
$css .= "\n".self::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>}
*/
public static 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.
*
* @param list<string> $light
* @param list<string> $dark
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
*/
public static function blocks(array $light, array $dark, array $scheme): string
{
$roles = fn (array $roles): string => collect($roles)
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
->implode("\n");
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
}
}