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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 19:29:21 +02:00
co-authored by Claude Opus 5
parent 471d927e64
commit 247c596c3a
233 changed files with 16635 additions and 10579 deletions
+18 -125
View File
@@ -23,31 +23,6 @@ use Throwable;
*/
class ErrorPage
{
/**
* The error layout's own stylesheet, which imports button.css and shape.css (and through
* button.css, icon.css, loading.css and tooltip.css) for what the layout renders.
*/
protected const string LAYOUT = 'components/error-page.css';
/**
* The package stylesheets the fallback bundles: the foundation (every application needs it)
* and the error layout's own stylesheet — the same rules a Vite build would serve the page,
* `Stylesheets::bundle()` inlining them in PHP instead. Not `all.css`: that would bundle every
* component's rules for a page that draws three of them.
*
* @var list<string>
*/
protected const array FILES = ['foundation.css', self::LAYOUT];
/**
* The scheme half of the fallback, cached per worker like `Stylesheets::bundle()`'s own cache:
* by the scheme file's path and mtime, so a request that never regenerates it never rebuilds
* this either, and a fresh `material:scheme` run is picked up the moment its mtime changes.
*
* @var array<string, array{css: string, mtime: int|false}>
*/
protected static array $schemeCache = [];
/**
* The application's Vite tags, or null when they cannot be made. They bring the foundation,
* the tokens, the scheme and the font; the layout's own rules come from `layoutStyles()`
@@ -88,10 +63,10 @@ class ErrorPage
}
/**
* The error layout's own rules for a page with its build: `Stylesheets::bundle()` of
* `components/error-page.css` and the stylesheets it imports, inlined beside the application's
* Vite tags, which bring everything else. `bundle()` caches it for the worker by its files'
* mtimes, as it does the fallback's half.
* The error layout's own rules for a page with its build: `components/error-page.css` and the
* stylesheets it imports, prebuilt into resources/dist/error-page.css (`npm run
* build:stylesheets`) and inlined beside the application's Vite tags, which bring everything
* else.
*
* Nothing is dropped from it, because nothing in it needs a build: the package's only
* `@font-face` is `tokens/font.css`'s, which only the foundation reaches and the application's
@@ -103,115 +78,33 @@ class ErrorPage
*/
public static function layoutStyles(): HtmlString
{
return new HtmlString(Stylesheets::bundle([self::path(self::LAYOUT)]));
return new HtmlString(self::dist('error-page.css'));
}
/**
* A stylesheet for a page without its build: the foundation and the error layout's own rules
* `Stylesheets::bundle()` of `self::FILES`, the same inlining a Vite build does, so the fallback
* can never drift from `resources/css/components/error-page.css` the way a hand-written copy
* did — with every `@font-face` dropped (`withoutFontFace()`: there is no build here to serve
* the font file, and a relative `url()` a request 404s on is worse than none) and the
* application's colours appended in `material-scheme.css`'s own shape (`schemeStylesheet()`):
* the standard level, the medium and high contrast levels under `[data-contrast]`, and a block
* per colour profile under `[data-scheme]`. `<x-theme-script>` has already written
* A stylesheet for a page without its build: the foundation and the error layout's own rules,
* prebuilt into resources/dist/error-page-fallback.css — the same inlining a Vite build does,
* so the fallback can never drift from `resources/css/components/error-page.css` the way a
* hand-written copy did, and without the `@font-face` (there is no build here to serve the font
* file; the brand typeface's stack in tokens/type.css falls back to the system fonts) — with
* the application's colours appended in `material-scheme.css`'s own shape
* (`SchemeStylesheet::withProfiles()`): the standard level, the medium and high contrast levels
* under `[data-contrast]`, and a block per colour profile under `[data-scheme]`, built from
* `Scheme::forStylesheet()` on every call. `<x-theme-script>` has already written
* `data-theme`, `data-contrast` and `data-scheme` onto `<html>` by the time this tag is parsed
* (it renders first), so the browser resolves the right block on its own — nothing here decides
* an active profile in PHP.
*/
public static function fallbackStyles(): HtmlString
{
$files = array_map(self::path(...), self::FILES);
return new HtmlString(self::withoutFontFace(Stylesheets::bundle($files)).self::schemeStylesheet());
return new HtmlString(self::dist('error-page-fallback.css').SchemeStylesheet::withProfiles(...Scheme::forStylesheet()));
}
/**
* A package stylesheet's absolute path, from its path under `resources/css/`.
* A prebuilt stylesheet under resources/dist/.
*/
protected static function path(string $file): string
protected static function dist(string $file): string
{
return dirname(__DIR__, 2)."/resources/css/{$file}";
}
/**
* `$css` with every `@font-face` block dropped, wherever it sits: not a search for the block's
* text (fragile the moment a comment or a value is reworded) but a structural read — find
* `@font-face` outside comments and strings, then remove the balanced `{ … }` that follows it,
* brace for brace, however the block itself is written. Both reads go through
* `Stylesheets::mask()`, because the bundle's comments name the rule too (foundation/base.css's
* and error-page.css's headers): read as the rule, such a mention cut from inside its comment to
* the end of the next balanced block, leaving the comment open over the rules after it —
* base.css's `html` rule and icon.css's rules left the fallback that way. `tokens/font.css`'s
* block is the only one the bundle carries, and it is also the bundle's only relative `url()`
* (button.css, icon.css, shape.css and the rest reference nothing on disk), so this removal is
* also what leaves the fallback with no `url()` to a font it cannot serve. The typeface itself
* degrades on its own: `--md-ref-typeface-brand` (tokens/type.css) lists `ui-sans-serif`,
* `system-ui` and `sans-serif` right after the brand name, so with no `@font-face` to resolve
* it the browser skips straight to that system stack — nothing here has to name one.
*/
protected static function withoutFontFace(string $css): string
{
$masked = Stylesheets::mask($css);
$result = '';
$offset = 0;
$length = strlen($css);
while (($start = stripos($masked, '@font-face', $offset)) !== false) {
$result .= substr($css, $offset, $start - $offset);
$open = strpos($masked, '{', $start);
if ($open === false) {
$offset = $start + strlen('@font-face');
continue;
}
$depth = 1;
$i = $open + 1;
while ($i < $length && $depth > 0) {
$depth += match ($masked[$i]) {
'{' => 1,
'}' => -1,
default => 0,
};
$i++;
}
$offset = $i;
}
return $result.substr($css, $offset);
}
/**
* The application's colours, in `material-scheme.css`'s own selector shape
* (`SchemeStylesheet::levels()`), from `Scheme::forStylesheet()` rather than a generated file
* that may not exist yet. Cached by the scheme file's path and mtime (`self::$schemeCache`):
* `Scheme` itself reads on every call and keeps nothing, so a resolver or a regenerated file
* applies at once everywhere else it is asked — this cache is safe only because the CSS built
* here never depends on which profile is *active*, only on the file's own content.
*/
protected static function schemeStylesheet(): string
{
$path = (string) config('livewire-material.scheme');
clearstatcache(true, $path);
$mtime = is_file($path) ? filemtime($path) : false;
if (isset(self::$schemeCache[$path]) && self::$schemeCache[$path]['mtime'] === $mtime) {
return self::$schemeCache[$path]['css'];
}
$scheme = Scheme::forStylesheet($path);
$css = SchemeStylesheet::levels($scheme['scheme']);
foreach ($scheme['profiles'] as $name => $profile) {
$css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']");
}
self::$schemeCache[$path] = ['css' => $css, 'mtime' => $mtime];
return $css;
return (string) file_get_contents(dirname(__DIR__, 2)."/resources/dist/{$file}");
}
}