From 3b9a889e7cc8fa66099e8f71376efc4a1d0896d3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 07:29:50 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- src/Console/SchemeCommand.php | 71 ++------------------------ src/Support/Scheme.php | 67 ++++++++++++++++++++++++ src/Support/SchemeStylesheet.php | 87 ++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 67 deletions(-) create mode 100644 src/Support/SchemeStylesheet.php diff --git a/src/Console/SchemeCommand.php b/src/Console/SchemeCommand.php index 0b89213a..54c22ae0 100644 --- a/src/Console/SchemeCommand.php +++ b/src/Console/SchemeCommand.php @@ -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 <<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, 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. - * - * @param list $light - * @param list $dark - * @param array{light: array, dark: array} $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"; - } } diff --git a/src/Support/Scheme.php b/src/Support/Scheme.php index 7a47237e..017fe35d 100644 --- a/src/Support/Scheme.php +++ b/src/Support/Scheme.php @@ -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 `` (`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, dark: array, contrast: array, dark: array}>}, profiles: array, dark: array, contrast: array, dark: array}>}>} + */ + 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 */ @@ -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 $data + * @return array{light: array, dark: array, contrast: array, dark: array}>} + */ + 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 $data * @param non-empty-array $profiles diff --git a/src/Support/SchemeStylesheet.php b/src/Support/SchemeStylesheet.php new file mode 100644 index 00000000..50c60eee --- /dev/null +++ b/src/Support/SchemeStylesheet.php @@ -0,0 +1,87 @@ +` 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 ``; standard is the + * scheme's own level and has no attribute of its own. + * + * @var list + */ + 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, dark: array}>, light: array, dark: array} $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, dark: array} $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 `` + * and the theme may be on `` or on a panel inside it, so both forms are written. + * + * @return array{list, list} + */ + 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 $light + * @param list $dark + * @param array{light: array, dark: array} $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"; + } +}