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>
280 lines
12 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|