Files
livewire-material/src/Support/Scheme.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

280 lines
12 KiB
PHP

<?php
namespace NoNameWeb\LivewireMaterial\Support;
use Closure;
use Illuminate\Support\Str;
use Throwable;
/**
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
* resolves none, an error page whose build is missing has no stylesheet to declare them, and the
* head script has to name the active colour profile before the stylesheet applies.
*
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
* file — or with one that is not a scheme — the package's own default applies, the same one
* tokens/scheme.css carries. A role the application's file lacks (a scheme generated before
* 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
* asked on every call, and a name it gives that is not in the file — or a resolver that throws, as
* one reading a database may while an error page for that very database renders — falls back to
* the file's default.
*
* Read on every call and never kept: a regenerated scheme or a newly chosen profile applies at
* once, and a long-running worker holds nothing but the resolver itself.
*/
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
*/
protected static ?Closure $profileResolver = null;
/**
* Name the active colour profile with the given closure, asked each time a colour is drawn.
*
* @param (Closure(): ?string)|null $resolver
*/
public static function resolveProfileUsing(?Closure $resolver): void
{
static::$profileResolver = $resolver;
}
/**
* 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, string $contrast = 'standard'): array
{
$data = static::data($path);
$profiles = static::profilesFrom($data, $contrast);
if ($profiles !== []) {
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
}
return static::scheme($data, $contrast);
}
/**
* The light roles: what a mail wears.
*
* @return array<string, string>
*/
public static function light(?string $path = null, ?string $profile = null, string $contrast = 'standard'): array
{
return static::load($path, $profile, $contrast)['light'];
}
/**
* The generated colour profiles, in the order they were configured; empty for a single scheme.
*
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
public static function profiles(?string $path = null, string $contrast = 'standard'): array
{
return static::profilesFrom(static::data($path), $contrast);
}
/**
* The active profile's name, or null for a single scheme.
*/
public static function profile(?string $path = null): ?string
{
$data = static::data($path);
$profiles = static::profilesFrom($data);
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 = collect(static::profilesFrom($data))
->map(fn (array $profile, string $name): array => ['label' => $profile['label'], ...static::fullScheme($data['profiles'][$name])])
->all();
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>
*/
protected static function data(?string $path): array
{
$path ??= (string) config('livewire-material.scheme');
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
return is_array($data) ? $data : [];
}
/**
* @param array<mixed> $data
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
*/
protected static function profilesFrom(array $data, string $contrast = 'standard'): array
{
if (! is_array($data['profiles'] ?? null)) {
return [];
}
$profiles = [];
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::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)],
];
}
/**
* 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
*/
protected static function activeFrom(array $data, array $profiles): string
{
try {
$resolved = static::$profileResolver ? (static::$profileResolver)() : null;
} catch (Throwable) {
$resolved = null;
}
foreach ([$resolved, $data['default'] ?? null] as $candidate) {
if (is_string($candidate) && isset($profiles[$candidate])) {
return $candidate;
}
}
return (string) array_key_first($profiles);
}
/**
* 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(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), ...static::roles($level['light'] ?? null)],
'dark' => [...static::roles($data['dark'] ?? null), ...static::roles($level['dark'] ?? null)],
];
}
/**
* @return array<string, string>
*/
protected static function roles(mixed $roles): array
{
if (! is_array($roles)) {
return [];
}
return array_filter(
$roles,
fn (mixed $hex, mixed $role): bool => is_string($role)
&& preg_match('/^[a-z-]+$/', $role) === 1
&& is_string($hex)
&& preg_match('/^#[0-9a-fA-F]{6}$/', $hex) === 1,
ARRAY_FILTER_USE_BOTH,
);
}
}