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
+38 -81
View File
@@ -18,10 +18,9 @@ use Symfony\Component\Finder\Finder;
* (i) any Tailwind utility or variant in a view, PHP or JS file — a breakpoint prefix, a
* cleared scale (radius, shadow, type size/weight/leading/tracking, easing, duration), a
* layout, spacing, sizing, position, border, effect, interactivity, text or display
* utility, a colour utility on a role, a 1.x ink, white or black (`text-meta`, `bg-white`,
* `text-on-surface/60`) or on a colour the application's own Tailwind theme named
* (`bg-brand`, `text-sport-run/60`), a 1.x package utility (`type-body-md`, `focus-ring`,
* `rounded-corner-lg`), a variant, or an arbitrary `[…]` value or property — each with its
* utility, a colour utility on a role, white or black (`bg-white`, `text-on-surface/60`)
* or on a colour the application's own Tailwind theme named (`bg-brand`,
* `text-sport-run/60`), a variant, or an arbitrary `[…]` value or property — each with its
* 2.0.0 replacement: a layout component and prop (`gap-4` → `gap="space200"`), an `md-*`
* class, or a token for the application's own CSS. A class the application's own
* stylesheets declare is exempt, and so is every `md-*` class.
@@ -101,14 +100,6 @@ class DesignGuard
'2xl' => 'extra-large',
];
/** The M3 breakpoint each window size class is, in px (foundations.md § Layout). */
protected const array WINDOW_PX = [
'medium' => 600,
'expanded' => 840,
'large' => 1200,
'extra-large' => 1600,
];
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
protected const array CORNERS = [
'none' => 'none',
@@ -142,12 +133,6 @@ class DesignGuard
* families are the line-by-line checks'.
*/
protected const array FAMILIES = [
// 1.x's own utilities, which compiled through the package's Tailwind theme until 2.0.0.
['/^(?:focus-ring|state-layer|touch-target|link)$/', '1.x utility `%s` compiles to nothing — use `md-%s` (interaction.css)'],
['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-corner-(?<corner>[a-z-]+)$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-{corner})` in your own CSS, or `<x-surface corner="{corner}">`'],
['/^shadow-elevation-(?<level>[0-5])$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-elevation-{level})` in your own CSS'],
['/^ease-(?<easing>(?:spatial|effects)-(?:fast|default|slow))$/', '1.x utility `%s` compiles to nothing — pair `var(--md-sys-motion-{easing})` with `var(--md-sys-motion-{easing}-duration)` in your own `transition`'],
['/^ease-(?<easing>standard|emphasized)(?<phase>-accelerate|-decelerate)?$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-motion-easing-{easing}{phase})` in your own `transition`'],
// Tailwind's cleared easing and duration scale: matched only here, in a class list a
// Blade or PHP file writes literally (never line-by-line across every file, the way
// outsideTheScale() still reads its other scale steps) — a plain PHP or JS string such as
@@ -205,17 +190,23 @@ class DesignGuard
'tabular-nums' => 'md-tabular',
];
/** A colour utility's prefix, up to its dash; `COLOUR_UTILITY` and `THEME_COLOUR_UTILITY` share it. */
protected const string COLOUR_PREFIX = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-';
/** A colour utility's opacity modifier (`/60`, `/[0.32]`, `/(--alpha)`), to the end of the token. */
protected const string COLOUR_OPACITY = '(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
/**
* A colour utility written on an M3 role, a 1.x ink name, or white/black/current, with an
* optional opacity modifier (`text-on-surface/60`, `bg-scrim/[0.32]`). The roles are every
* `--md-sys-color-*` the default scheme and elevation tokens declare.
* A colour utility written on an M3 role, or white/black/current, with an optional opacity
* modifier (`text-on-surface/60`, `bg-scrim/[0.32]`). The roles are every `--md-sys-color-*`
* the default scheme and elevation tokens declare.
*/
protected const string COLOUR_UTILITY = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
protected const string COLOUR_UTILITY = self::COLOUR_PREFIX
.'(?<role>(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container|-dim|-fixed(?:-dim|-variant)?)?'
.'|inverse-(?:primary|surface|on-surface|error|success|warning|info)'
.'|(?:on-)?background|(?:on-)?surface(?:-variant|-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?'
.'|outline(?:-variant)?|scrim|shadow|body|meta|quiet|structure|chrome|divider|white|black|current|transparent|inherit|initial)'
.'(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
.'|outline(?:-variant)?|scrim|shadow|white|black|current|transparent|inherit|initial)'
.self::COLOUR_OPACITY;
/**
* A colour utility on any other name (`bg-off-plan`, `text-sport-run`, `border-l-zone-4`,
@@ -225,8 +216,7 @@ class DesignGuard
* `bg-cover`, `shadow-none`) is reported as that family; the name starts with a letter, so a
* width or a stop (`border-2`, `from-10%`) never reads as one.
*/
protected const string THEME_COLOUR_UTILITY = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
protected const string THEME_COLOUR_UTILITY = self::COLOUR_PREFIX.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)'.self::COLOUR_OPACITY;
/**
* The hooks an application writes on markup of its own, not through a component tag, whose
@@ -246,16 +236,6 @@ class DesignGuard
'data-md-list-row' => ['stylesheet' => 'components/list-item.css', 'except' => ['card']],
];
/** 1.x's semantic ink names, and the M3 role each resolved to (UPGRADE.md § 4). */
protected const array INK_ALIASES = [
'body' => 'on-surface-variant',
'meta' => 'on-surface-variant',
'quiet' => 'outline',
'structure' => 'outline-variant',
'chrome' => 'outline-variant',
'divider' => 'outline-variant',
];
/** An ink role, and the `md-ink-*` class (text.css) that sets it on plain text. */
protected const array INK_ROLE = [
'on-surface' => 'md-ink',
@@ -406,7 +386,7 @@ class DesignGuard
if ($this->cssEntry !== null && ! is_file($this->cssEntry)) {
$violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist";
} elseif ($this->cssEntry !== null) {
$foundation = static::packageCssRoot().'/foundation.css';
$foundation = static::packagePath('css').'/foundation.css';
if (! isset($resolved[$foundation])) {
$violations[] = sprintf(
@@ -428,7 +408,8 @@ class DesignGuard
$where = $this->relative($file->getPathname());
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packageViewRoot()]));
// A hook written in the package's own views is a tag's own, so hooks are read only outside them.
$readsHooks = ! $this->isUnder($file->getPathname(), array_filter([static::packagePath('views')]));
if ($isBlade) {
$contents = $this->withoutBladeComments($contents);
@@ -520,7 +501,7 @@ class DesignGuard
}
if ($this->paginationUsages($contents) !== []) {
$needed[] = static::packageCssRoot().'/components/pagination.css';
$needed[] = static::packagePath('css').'/components/pagination.css';
}
return $needed;
@@ -539,7 +520,7 @@ class DesignGuard
return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"];
}
$root = static::packageCssRoot();
$root = static::packagePath('css');
// Comments out, line count kept; the import strings themselves stay readable.
$css = (string) preg_replace_callback('~/\*.*?\*/~s', fn (array $comment): string => str_repeat("\n", substr_count($comment[0], "\n")), (string) file_get_contents($real));
$imports = [];
@@ -730,7 +711,7 @@ class DesignGuard
return array_map(function (array $match): string {
$max = $match['max'] ?? '';
$name = self::WINDOW_CLASSES[$match['name']];
$px = self::WINDOW_PX[$name];
$px = Layout::BREAKPOINTS[$name];
$comparison = $max !== '' ? "width < {$px}px" : "width >= {$px}px";
return "Tailwind breakpoint `{$max}{$match['name']}:` compiles to nothing — M3's {$name} ({$px}px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media ({$comparison})` in your own CSS";
@@ -825,10 +806,6 @@ class DesignGuard
return "Tailwind variant class `{$token}` compiles to nothing — ".$this->variantHint($variant['lead']);
}
if (preg_match('/^(?:type-(?:emphasized-)?(?:display|headline|title|body|label)-(?:lg|md|sm))$/', $token) === 1) {
return "1.x utility `{$token}` compiles to nothing — use `md-{$token}` (text.css)";
}
if (preg_match('/^\[(?<property>-{0,2}[a-z][\w-]*):[^\]]+\]$/', $token, $arbitrary) === 1) {
return "Tailwind arbitrary property `{$token}` compiles to nothing — write `{$arbitrary['property']}` in your own CSS";
}
@@ -890,16 +867,14 @@ class DesignGuard
/**
* The 2.0.0 replacement for a Tailwind variant, by its first segment: a state, the theme, a
* structural pseudo-class, a direction, an M3 breakpoint variant 1.x compiled, or an
* arbitrary selector.
* structural pseudo-class, a direction, an M3 breakpoint, or an arbitrary selector.
*/
protected function variantHint(string $lead): string
{
$m3 = ['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600];
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
return match (true) {
isset($m3[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<')." {$m3[$bare]}px)` in your own CSS, or a layout component's `hide-below`/`hide-from`/`stack-below` prop",
$bare !== 'compact' && isset(Layout::BREAKPOINTS[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<').' '.Layout::BREAKPOINTS[$bare]."px)` in your own CSS, or a layout component's `hide-below`/`hide-from`/`stack-below` prop",
$lead === 'dark' => "the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS",
$lead === 'rail-collapsed' => "`:root[data-rail='collapsed'] …` in your own CSS",
in_array($lead, ['motion-reduce', 'motion-safe'], true) => '`@media (prefers-reduced-motion: reduce)` in your own CSS',
@@ -999,7 +974,7 @@ class DesignGuard
}
$utility = $m['utility'];
$role = self::INK_ALIASES[$m['role']] ?? $m['role'];
$role = $m['role'];
$dead = "Tailwind colour utility `{$token}` compiles to nothing";
return match (true) {
@@ -1553,7 +1528,7 @@ class DesignGuard
preg_match_all('/(?<![\w`\[-])'.preg_quote($hook, '/').'(?![\w-])/', $markup, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [, $offset]) {
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packageCssRoot().'/'.$stylesheet];
$found[] = [substr_count(substr($markup, 0, $offset), "\n") + 1, $hook, static::packagePath('css').'/'.$stylesheet];
}
}
@@ -1622,7 +1597,7 @@ class DesignGuard
}
foreach ($this->paginationUsages($contents) as $line) {
$needs[] = [$line, '`->links()`', static::packageCssRoot().'/components/pagination.css'];
$needs[] = [$line, '`->links()`', static::packagePath('css').'/components/pagination.css'];
}
usort($needs, fn (array $a, array $b): int => $a[0] <=> $b[0]);
@@ -1762,42 +1737,24 @@ class DesignGuard
*/
protected static function isPackageFile(string $file): bool
{
$root = static::packageCssRoot();
$root = static::packagePath('css');
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
}
/**
* This package's own `resources/css`, from this file's own location — works whether the class
* loads from `vendor/nonameweb/livewire-material` (a consuming application) or from this
* repository itself, the same trick `Support\Stylesheets::name()` uses.
* A folder of this package's own `resources/` (`css`, `views`, `views/components`), from this
* file's own location — works whether the class loads from `vendor/nonameweb/livewire-material`
* (a consuming application) or from this repository itself. Empty when it does not exist.
*/
protected static function packageCssRoot(): string
protected static function packagePath(string $folder): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/css');
}
/**
* This package's own `resources/views/components` — every component and layout component
* lives here, so a tag's own view existing there is what "is this a package tag" checks.
*/
protected static function packageComponentViewRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/views/components');
}
/**
* This package's own `resources/views`: its components, showcase, pagination and error pages.
* A hook written there is a tag's own, so check (ii) reads hooks only outside it.
*/
protected static function packageViewRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/views');
return (string) realpath(dirname(__DIR__, 2)."/resources/{$folder}");
}
protected static function isPackageTag(string $name): bool
{
$root = static::packageComponentViewRoot();
$root = static::packagePath('views/components');
return $root !== '' && is_file("{$root}/{$name}.blade.php");
}
@@ -1815,7 +1772,7 @@ class DesignGuard
}
foreach (['components', 'layout'] as $group) {
$file = static::packageCssRoot()."/{$group}/{$name}.css";
$file = static::packagePath('css')."/{$group}/{$name}.css";
if (is_file($file)) {
return $file;
@@ -1827,7 +1784,7 @@ class DesignGuard
protected static function packageRelativeName(string $file): string
{
$root = static::packageCssRoot().'/';
$root = static::packagePath('css').'/';
return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file;
}
@@ -1849,8 +1806,8 @@ class DesignGuard
$vendor = base_path('vendor/nonameweb/livewire-material/resources/css');
// A Composer path repository may symlink the package: the import still goes through vendor/.
if (realpath($vendor) === static::packageCssRoot() && str_starts_with($file, static::packageCssRoot().'/')) {
$file = $vendor.substr($file, strlen(static::packageCssRoot()));
if (realpath($vendor) === static::packagePath('css') && str_starts_with($file, static::packagePath('css').'/')) {
$file = $vendor.substr($file, strlen(static::packagePath('css')));
}
return static::relativeImportPath($entryDir, $file);