Retire forbidAbsolutes() and forbidOpacityInk() from the design guard

Plan step 41 review (the user, 2026-09-15): without Tailwind, bg-white and
text-on-surface/60 compile to nothing like any other utility, so family (i)
now reports every colour utility on an M3 role, a 1.x ink name
(text-meta, border-divider), white, black or current, with or without an
opacity, each with its 2.0.0 replacement (an md-ink-* class, <x-divider>,
<x-surface level>, md-state-layer or the role's var()). Check (iii) reads
named colours anywhere in a declaration's value (border: 1px solid white),
hex codes only in values (an id like #add no longer matches), and masks
strings and url() contents first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 08:33:20 +02:00
co-authored by Claude Opus 5
parent c047fefd8b
commit 6dc996afdf
6 changed files with 146 additions and 148 deletions
-2
View File
@@ -154,8 +154,6 @@ The guard fails on Tailwind palette colours, unknown Material Symbol names and B
`missingStylesheets($cssEntry)` checks the CSS entry's `@import` graph (followed through every package file's own imports) against the package tags a view actually renders — unprefixed, under the configured prefix, or `<x-livewire-material::…>` — and `->links()`; each missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths), which reports a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or off-scale media query with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine, and the generated `material-scheme.css` is skipped. `missingStylesheets($cssEntry)` checks the CSS entry's `@import` graph (followed through every package file's own imports) against the package tags a view actually renders — unprefixed, under the configured prefix, or `<x-livewire-material::…>` — and `->links()`; each missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths), which reports a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or off-scale media query with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine, and the generated `material-scheme.css` is skipped.
Two checks are opt-in, and matter mainly to an application still migrating off Tailwind: `forbidAbsolutes()` also fails on `bg-white` and `text-black` (M3's white is `surface-container-lowest`), and `forbidOpacityInk()` on opacity used as emphasis (`text-on-surface/60``text-on-surface-variant` or `text-outline`). M3 reserves 38 % on content and 12 % on a container for the disabled state, which is what the package's own components use them for, so neither is on by default.
## AI agents ## AI agents
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's two guidelines — the library's own, and `material-3`, a page of M3's rules an agent reads in every session — and two skills: `livewire-material-development` (every component, prop, slot and trap) and `material-3-design` (M3's colour roles, surfaces, elevation, shape, type, motion, states, window size classes and accessibility, with the library's utility beside each M3 name and Google's source page for each chapter). With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's two guidelines — the library's own, and `material-3`, a page of M3's rules an agent reads in every session — and two skills: `livewire-material-development` (every component, prop, slot and trap) and `material-3-design` (M3's colour roles, surfaces, elevation, shape, type, motion, states, window size classes and accessibility, with the library's utility beside each M3 name and Google's source page for each chapter).
@@ -1113,8 +1113,6 @@ it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()]) expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
->missingStylesheets(resource_path('css/app.css')) ->missingStylesheets(resource_path('css/app.css'))
->forbidColours(['tertiary']) // roles this application's rules leave out ->forbidColours(['tertiary']) // roles this application's rules leave out
->forbidAbsolutes() // opt-in, migration only: `bg-white`, `text-black`
->forbidOpacityInk() // opt-in, migration only: `text-on-surface/60`
->violations())->toBe([]); ->violations())->toBe([]);
}); });
``` ```
@@ -1132,10 +1130,12 @@ It also fails on every Tailwind utility or variant, which compile to nothing in
| `hidden` | a layout component's `hide-below`/`hide-from` prop, or `[hidden]` | | `hidden` | a layout component's `hide-below`/`hide-from` prop, or `[hidden]` |
| `block`, `inline-block`, `invisible` | a plain `display` rule in your own CSS | | `block`, `inline-block`, `invisible` | a plain `display` rule in your own CSS |
| `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` | | `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` |
| `text-on-surface-variant`, `text-primary` … | `md-ink-variant`, `md-ink-primary` … | | `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … |
| `border-outline-variant` | `<x-divider>` or `<x-surface outlined>` | | `border-outline-variant` | `<x-divider>` or `<x-surface outlined>` |
| `bg-surface-container` | `<x-surface level="surface-container">` | | `bg-surface-container` | `<x-surface level="surface-container">` |
| `bg-primary`, `border-error` … | `var(--md-sys-color-*)` | | `bg-primary`, `border-error` … | `var(--md-sys-color-*)` |
| `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black |
| `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay |
| `rounded-lg`, `rounded-t-2xl`, `rounded-full` | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` | | `rounded-lg`, `rounded-t-2xl`, `rounded-full` | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` |
| `shadow-sm`, `shadow-md``shadow-2xl` | `var(--md-sys-elevation-1)``var(--md-sys-elevation-5)` | | `shadow-sm`, `shadow-md``shadow-2xl` | `var(--md-sys-elevation-1)``var(--md-sys-elevation-5)` |
| `text-sm`, `leading-6`, `tracking-wide` | one of the `md-type-*` classes, which set size, line height and tracking together | | `text-sm`, `leading-6`, `tracking-wide` | one of the `md-type-*` classes, which set size, line height and tracking together |
@@ -1146,8 +1146,6 @@ It also fails on every Tailwind utility or variant, which compile to nothing in
`missingStylesheets($cssEntry)` follows the entry's `@import` graph through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too) and checks it against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; a missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead — the application's component wins in Blade. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths, `material-scheme.css` excluded): a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or a media query off M3's 600/840/1200/1600px scale, each with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine. `missingStylesheets($cssEntry)` follows the entry's `@import` graph through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too) and checks it against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; a missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead — the application's component wins in Blade. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths, `material-scheme.css` excluded): a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or a media query off M3's 600/840/1200/1600px scale, each with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine.
`forbidAbsolutes()` adds `white` and `black` (M3's white is `surface-container-lowest`, its ink an `on-` role) and `forbidOpacityInk()` adds opacity as emphasis (`text-on-surface/60``text-on-surface-variant` or `text-outline`). Both are off by default and matter mainly to an application still migrating off Tailwind: M3 reserves 38 % on content and 12 % on a container for the disabled state, and the package's own components are written with those two opacities.
## Conventions ## Conventions
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works. - Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
+101 -104
View File
@@ -35,13 +35,10 @@ use Symfony\Component\Finder\Finder;
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains. * `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
* (iv) Tailwind palette colours, icon names that are not Material Symbols, and Blade * (iv) Tailwind palette colours, icon names that are not Material Symbols, and Blade
* directives written inside a component tag (where they do not compile) plus whatever * directives written inside a component tag (where they do not compile) plus whatever
* an application bans on top with `forbidColours()`, `forbidAbsolutes()`, * an application bans on top with `forbidColours()` and `forbid()`. These, and the
* `forbidOpacityInk()` and `forbid()`. These, and the breakpoint, * breakpoint, scale and colour-value checks, read a line at a time, so a class assembled
* scale and colour-value checks, read a line at a time, so a class assembled at runtime * at runtime (`'text-'.$tone`) or hidden in a comment stays invisible the same reason
* (`'text-'.$tone`) or hidden in a comment stays invisible the same reason to write * to write class names out whole.
* class names out whole. `forbidAbsolutes()` and `forbidOpacityInk()` matter mainly to an
* application still migrating off Tailwind, where the package's own opacity-based
* disabled state is still the only legitimate use of either.
* *
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()]) * expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
* ->missingStylesheets(resource_path('css/app.css')) * ->missingStylesheets(resource_path('css/app.css'))
@@ -69,12 +66,6 @@ class DesignGuard
/** A colour written as a value: an arbitrary hex, function or mix instead of a role. */ /** A colour written as a value: an arbitrary hex, function or mix instead of a role. */
protected const string ARBITRARY_COLOUR = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/'; protected const string ARBITRARY_COLOUR = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/';
/** The two absolutes. M3's white is a surface role, not a colour. */
protected const string ABSOLUTE_COLOUR = '/(?<![\w-])'.self::UTILITY.'-(?:white|black)(?![\w-])/';
/** Opacity used as emphasis on ink, which M3 expresses as a role. */
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
/** /**
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's * Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
* 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/1600, so a prefix maps to the * 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/1600, so a prefix maps to the
@@ -156,7 +147,29 @@ class DesignGuard
'tabular-nums' => 'md-tabular', 'tabular-nums' => 'md-tabular',
]; ];
/** An ink role Tailwind's `text-*` utility named, and the `md-ink-*` class that replaces it. */ /**
* 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.
*/
protected const string COLOUR_UTILITY = '/^(?<utility>bg|text|border(?:-[trblxyse])?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
.'(?<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)'
.'(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]))?$/';
/** 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 = [ protected const array INK_ROLE = [
'on-surface' => 'md-ink', 'on-surface' => 'md-ink',
'on-surface-variant' => 'md-ink-variant', 'on-surface-variant' => 'md-ink-variant',
@@ -169,6 +182,12 @@ class DesignGuard
'inverse-on-surface' => 'md-ink-inverse', 'inverse-on-surface' => 'md-ink-inverse',
]; ];
/** The roles `<x-surface level>` takes. */
protected const array SURFACE_LEVELS = [
'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low',
'surface-container', 'surface-container-high', 'surface-container-highest',
];
/** The named colours a browser understands, other than `transparent` and `currentColor`. */ /** The named colours a browser understands, other than `transparent` and `currentColor`. */
protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen'; protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen';
@@ -200,10 +219,6 @@ class DesignGuard
/** @var list<string> */ /** @var list<string> */
protected array $forbiddenColours = []; protected array $forbiddenColours = [];
protected bool $forbiddenAbsolutes = false;
protected bool $forbiddenOpacityInk = false;
protected ?string $cssEntry = null; protected ?string $cssEntry = null;
/** @var array<string, true>|null */ /** @var array<string, true>|null */
@@ -235,35 +250,6 @@ class DesignGuard
return $this; return $this;
} }
/**
* Also ban the two absolutes. M3 paints with roles only: the white of a light page is
* `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled
* button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet
* sometimes does mean the absolute and because, until an application finishes leaving
* Tailwind, `bg-white`/`text-black` may still be sitting in an untouched view.
*/
public function forbidAbsolutes(): static
{
$this->forbiddenAbsolutes = true;
return $this;
}
/**
* Also ban opacity as emphasis on ink: M3 says secondary text is a role on-surface-variant
* for supporting text, outline for the quietest never a faded on-surface. It reserves two
* opacities for the disabled state, 38 % on content and 12 % on a container, and the
* package's own disabled styles are written with those two, which is why this is off by
* default; an application that draws its disabled states from the components does not need
* either opacity of its own.
*/
public function forbidOpacityInk(): static
{
$this->forbiddenOpacityInk = true;
return $this;
}
/** /**
* Any further pattern, matched line by line. * Any further pattern, matched line by line.
*/ */
@@ -491,38 +477,15 @@ class DesignGuard
} }
/** /**
* Colours written as a value rather than a role: an arbitrary one always, the two absolutes * Colours written as a value rather than a role: an arbitrary hex, function or mix.
* and opacity on ink when the application asks for them.
* *
* @return list<string> * @return list<string>
*/ */
protected function colourValues(string $text): array protected function colourValues(string $text): array
{ {
$found = [];
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches); preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
foreach ($matches[0] as $class) { return array_map(fn (string $class): string => "arbitrary colour `{$class}`, use an M3 role", $matches[0]);
$found[] = "arbitrary colour `{$class}`, use an M3 role";
}
if ($this->forbiddenAbsolutes) {
preg_match_all(self::ABSOLUTE_COLOUR, $text, $matches);
foreach ($matches[0] as $class) {
$found[] = "absolute colour `{$class}`, use an M3 role";
}
}
if ($this->forbiddenOpacityInk) {
preg_match_all(self::OPACITY_INK, $text, $matches);
foreach ($matches[0] as $class) {
$found[] = "opacity on ink `{$class}`, use a role (`text-on-surface-variant`, `text-outline`)";
}
}
return $found;
} }
/** /**
@@ -585,23 +548,35 @@ class DesignGuard
return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)'; return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)';
} }
if (preg_match('/^text-(?<role>on-surface(?:-variant)?|outline|primary|error|success|warning|info|inverse-on-surface)$/', $token, $m) === 1) { return $this->colourUtilityHint($token);
return "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)'; }
/**
* A colour utility's 2.0.0 replacement (see `COLOUR_UTILITY`), or null for anything else: an
* ink on plain text is its `md-ink-*` class, a line `<x-divider>` or `<x-surface outlined>`, a
* tonal background `<x-surface level>`, white and black a role, opacity a role or the state
* layer, and every other role its `var(--md-sys-color-*)` in the application's own CSS.
*/
protected function colourUtilityHint(string $token): ?string
{
if (preg_match(self::COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
return null;
} }
if (preg_match('/^(?:border(?:-[trblxyse])?|divide|ring)-outline(?:-variant)?$/', $token) === 1) { $utility = $m['utility'];
return "Tailwind line role `{$token}` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility"; $role = self::INK_ALIASES[$m['role']] ?? $m['role'];
} $dead = "Tailwind colour utility `{$token}` compiles to nothing";
if (preg_match('/^bg-(?<role>surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) { return match (true) {
return "Tailwind surface role `{$token}` compiles to nothing — use `<x-surface level=\"{$m['role']}\">`"; in_array($role, ['white', 'black'], true) => "{$dead} — M3 paints with roles, never white or black: `<x-surface level=\"surface-container-lowest\">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink",
} in_array($role, ['current', 'transparent'], true) => "{$dead} — write `".($role === 'current' ? 'currentColor' : 'transparent').'` in your own CSS',
$m['opacity'] !== null && $utility === 'text' => "{$dead} — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
if (preg_match('/^(?:bg|border(?:-[trblxyse])?|ring|fill|stroke|divide|decoration|accent|caret|outline)-(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container)?$/', $token) === 1) { $m['opacity'] !== null => "{$dead} — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-{$role}) <n>%, transparent)` in your own CSS",
return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS"; $utility === 'text' && isset(self::INK_ROLE[$role]) => "{$dead} — use `".self::INK_ROLE[$role].'` (text.css)',
} preg_match('/^(?:border(?:-[trblxyse])?|divide)$/', $utility) === 1 && in_array($role, ['outline', 'outline-variant'], true) => "{$dead} — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility",
$utility === 'bg' && in_array($role === 'background' ? 'surface' : $role, self::SURFACE_LEVELS, true) => "{$dead} — use `<x-surface level=\"".($role === 'background' ? 'surface' : $role).'">`',
return null; default => "{$dead} — use `var(--md-sys-color-{$role})` in your own CSS",
};
} }
/** /**
@@ -617,7 +592,7 @@ class DesignGuard
$violations = []; $violations = [];
foreach ($this->applicationStylesheets() as $file) { foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutTokenFunctions($this->withoutCssComments((string) file_get_contents($file))); $css = $this->withoutTokenFunctions($this->maskedCss((string) file_get_contents($file)));
$where = $this->relative($file); $where = $this->relative($file);
foreach ([ foreach ([
@@ -633,25 +608,43 @@ class DesignGuard
} }
/** /**
* Every literal colour in `$css`: a hex code, a colour function, or a named colour other than * Every literal colour in a declaration's value: a hex code, a colour function, or a named
* `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) * colour other than `transparent`/`currentColor`, anywhere in the value (`border: 1px solid
* which is what keeps a selector like `.red-banner` from matching. * white` as much as `color: white`). Only values are read, so a selector like `.red-banner` or
* an id like `#add` never matches.
* *
* @return list<array{0: int, 1: string}> * @return list<array{0: int, 1: string}>
*/ */
protected function literalColours(string $css): array protected function literalColours(string $css): array
{ {
$pattern = '/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab|color)\([^)]*\)|(?<=[:,(]\s{0,20})(?:'.self::CSS_NAMED_COLOURS.')\b(?!-))/i'; $found = [];
preg_match_all($pattern, $css, $matches, PREG_OFFSET_CAPTURE); foreach ($this->declarations($css) as [, $value, $offset]) {
preg_match_all('/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}(?![\w-])|(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)|(?:'.self::CSS_NAMED_COLOURS.')(?![\w-]))/i', $value, $matches, PREG_OFFSET_CAPTURE);
return array_map( foreach ($matches[0] as [$colour, $at]) {
fn (array $match): array => [ $found[] = [
substr_count(substr($css, 0, $match[1]), "\n") + 1, substr_count(substr($css, 0, $offset + $at), "\n") + 1,
"literal colour `{$match[0]}` — use `var(--md-sys-color-*)`", "literal colour `{$colour}` — use `var(--md-sys-color-*)`",
], ];
$matches[0], }
); }
return $found;
}
/**
* Every declaration in `$css` a property, custom properties included, and its value up to
* the `;` or `}` that ends it with the value's byte offset. A selector (`a:hover {`) or an
* at-rule condition (`@media (prefers-color-scheme: dark) {`) ends in `{` and is never one.
*
* @return list<array{0: string, 1: string, 2: int}>
*/
protected function declarations(string $css): array
{
preg_match_all('/(?<![\w-])(?<property>--[\w-]+|-?[a-zA-Z][\w-]*)\s*:\s*(?<value>[^;{}]*)(?=[;}])/', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
return array_map(fn (array $match): array => [strtolower($match['property'][0]), $match['value'][0], $match['value'][1]], $matches);
} }
/** /**
@@ -769,13 +762,17 @@ class DesignGuard
} }
/** /**
* `$css` with every `/* … *\/` comment blanked out, line breaks kept. * `$css` with every `/* … *\/` comment, the inside of every quoted string and of every
* unquoted `url(…)` blanked out, line breaks kept so neither a class check nor a value check
* reads `content: ".flex"`, a font name or a data URI's `fill='white'`.
*/ */
protected function withoutCssComments(string $css): string protected function maskedCss(string $css): string
{ {
return (string) preg_replace_callback( return (string) preg_replace_callback(
'/\/\*.*?\*\//s', '/\/\*.*?\*\/|"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'|(?<=url\()[^)\'"]*(?=\))/is',
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")), fn (array $match): string => str_starts_with($match[0], '/*')
? str_repeat("\n", substr_count($match[0], "\n"))
: (string) preg_replace('/[^\n]/', ' ', $match[0]),
$css, $css,
); );
} }
@@ -1097,7 +1094,7 @@ class DesignGuard
$classes = []; $classes = [];
foreach ($this->applicationStylesheets() as $file) { foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutCssComments((string) file_get_contents($file)); $css = $this->maskedCss((string) file_get_contents($file));
preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches); preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches);
+27 -37
View File
@@ -24,6 +24,7 @@ it('finds what compiles to nothing', function () {
expect(fixtureRelative($violations))->toBe([ expect(fixtureRelative($violations))->toBe([
'app/Status.php:14 colour the theme does not declare `text-red-500`', 'app/Status.php:14 colour the theme does not declare `text-red-500`',
"views/page.blade.php:2 Tailwind spacing utility `p-4` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS", "views/page.blade.php:2 Tailwind spacing utility `p-4` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS",
'views/page.blade.php:6 Tailwind colour utility `text-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
'views/page.blade.php:8 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits', 'views/page.blade.php:8 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits',
'views/page.blade.php:3 unknown Material Symbol `o-home`', 'views/page.blade.php:3 unknown Material Symbol `o-home`',
'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`', 'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`',
@@ -150,6 +151,12 @@ it('sends an easing and a duration to the motion tokens', function () {
it('finds a colour written as a value', function () { it('finds a colour written as a value', function () {
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations()))->toBe([ expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations()))->toBe([
'colour/badge.blade.php:3 Tailwind colour utility `bg-white` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
'colour/badge.blade.php:3 Tailwind colour utility `text-black` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
'colour/badge.blade.php:3 Tailwind colour utility `border-white` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
"colour/badge.blade.php:4 Tailwind colour utility `text-on-surface/60` compiles to nothing — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
"colour/badge.blade.php:4 Tailwind colour utility `bg-on-surface/12` compiles to nothing — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-on-surface) <n>%, transparent)` in your own CSS",
"colour/badge.blade.php:4 Tailwind colour utility `border-outline/38` compiles to nothing — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-outline) <n>%, transparent)` in your own CSS",
'colour/badge.blade.php:1 arbitrary colour `bg-[#1d7afc]`, use an M3 role', 'colour/badge.blade.php:1 arbitrary colour `bg-[#1d7afc]`, use an M3 role',
'colour/badge.blade.php:1 arbitrary colour `text-[rgb(0_0_0)]`, use an M3 role', 'colour/badge.blade.php:1 arbitrary colour `text-[rgb(0_0_0)]`, use an M3 role',
'colour/badge.blade.php:1 arbitrary colour `border-[hsl(210_80%_50%)]`, use an M3 role', 'colour/badge.blade.php:1 arbitrary colour `border-[hsl(210_80%_50%)]`, use an M3 role',
@@ -158,14 +165,27 @@ it('finds a colour written as a value', function () {
]); ]);
}); });
it('sends 1.x ink names, the state-layer opacities and every other role to their 2.0.0 replacement', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/inks.blade.php'))->violations());
expect($violations)->toBe([
'tailwind/inks.blade.php:1 Tailwind colour utility `text-meta` compiles to nothing — use `md-ink-variant` (text.css)',
'tailwind/inks.blade.php:1 Tailwind colour utility `text-quiet` compiles to nothing — use `md-ink-quiet` (text.css)',
'tailwind/inks.blade.php:1 Tailwind colour utility `border-divider` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility',
'tailwind/inks.blade.php:2 Tailwind colour utility `text-on-primary-container` compiles to nothing — use `var(--md-sys-color-on-primary-container)` in your own CSS',
'tailwind/inks.blade.php:2 Tailwind colour utility `bg-background` compiles to nothing — use `<x-surface level="surface">`',
'tailwind/inks.blade.php:2 Tailwind colour utility `text-current` compiles to nothing — write `currentColor` in your own CSS',
]);
});
it('names the md-ink-*, divider/surface or token replacement for a dead M3 role utility', function () { it('names the md-ink-*, divider/surface or token replacement for a dead M3 role utility', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/roles.blade.php'))->violations()); $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/roles.blade.php'))->violations());
expect($violations)->toBe([ expect($violations)->toBe([
'tailwind/roles.blade.php:1 Tailwind ink role `text-on-surface-variant` compiles to nothing — use `md-ink-variant` (text.css)', 'tailwind/roles.blade.php:1 Tailwind colour utility `text-on-surface-variant` compiles to nothing — use `md-ink-variant` (text.css)',
'tailwind/roles.blade.php:1 Tailwind line role `border-outline-variant` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility', 'tailwind/roles.blade.php:1 Tailwind colour utility `border-outline-variant` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility',
'tailwind/roles.blade.php:2 Tailwind surface role `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`', 'tailwind/roles.blade.php:2 Tailwind colour utility `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`',
'tailwind/roles.blade.php:2 Tailwind role utility `bg-primary` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS', 'tailwind/roles.blade.php:2 Tailwind colour utility `bg-primary` compiles to nothing — use `var(--md-sys-color-primary)` in your own CSS',
]); ]);
}); });
@@ -258,38 +278,6 @@ it('reports an arbitrary [] value once, whatever utility it modifies, and strips
]); ]);
}); });
it('bans the two absolutes when asked', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidAbsolutes()->violations());
expect($violations)->toContain(
'colour/badge.blade.php:3 absolute colour `bg-white`, use an M3 role',
'colour/badge.blade.php:3 absolute colour `text-black`, use an M3 role',
'colour/badge.blade.php:3 absolute colour `border-white`, use an M3 role',
);
});
it('leaves the two absolutes alone by default', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations());
expect($violations)->not->toContain('colour/badge.blade.php:3 absolute colour `bg-white`, use an M3 role');
});
it('bans opacity as emphasis on ink when asked', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidOpacityInk()->violations());
expect($violations)->toContain(
'colour/badge.blade.php:4 opacity on ink `text-on-surface/60`, use a role (`text-on-surface-variant`, `text-outline`)',
'colour/badge.blade.php:4 opacity on ink `bg-on-surface/12`, use a role (`text-on-surface-variant`, `text-outline`)',
'colour/badge.blade.php:4 opacity on ink `border-outline/38`, use a role (`text-on-surface-variant`, `text-outline`)',
);
});
it('leaves opacity on ink alone by default, as the package\'s own disabled styles use it', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations());
expect($violations)->not->toContain('colour/badge.blade.php:4 opacity on ink `text-on-surface/60`, use a role (`text-on-surface-variant`, `text-outline`)');
});
it('bans the roles an application leaves out', function () { it('bans the roles an application leaves out', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views')) $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views'))
->forbidColours(['tertiary']) ->forbidColours(['tertiary'])
@@ -414,6 +402,8 @@ it('finds every literal design value and off-scale media query in the applicatio
'app-css/app.css:3 literal colour `#ff0000` — use `var(--md-sys-color-*)`', 'app-css/app.css:3 literal colour `#ff0000` — use `var(--md-sys-color-*)`',
'app-css/app.css:4 literal colour `rgb(0, 0, 0)` — use `var(--md-sys-color-*)`', 'app-css/app.css:4 literal colour `rgb(0, 0, 0)` — use `var(--md-sys-color-*)`',
'app-css/app.css:6 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`', 'app-css/app.css:6 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`',
'app-css/app.css:24 literal colour `white` — use `var(--md-sys-color-*)`',
'app-css/app.css:25 literal colour `#fff` — use `var(--md-sys-color-*)`',
'app-css/app.css:5 literal radius `border-radius: 12px` — use `var(--md-sys-shape-corner-*)`', 'app-css/app.css:5 literal radius `border-radius: 12px` — use `var(--md-sys-shape-corner-*)`',
'app-css/app.css:6 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`', 'app-css/app.css:6 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`',
'app-css/app.css:7 literal font size `font-size: 14px` — use `var(--md-sys-typescale-*)`, which sets size, line height and weight together', 'app-css/app.css:7 literal font size `font-size: 14px` — use `var(--md-sys-typescale-*)`, which sets size, line height and weight together',
@@ -422,7 +412,7 @@ it('finds every literal design value and off-scale media query in the applicatio
'app-css/app.css:10 literal letter spacing `letter-spacing: 0.02em` — use `var(--md-sys-typescale-*-tracking)`', 'app-css/app.css:10 literal letter spacing `letter-spacing: 0.02em` — use `var(--md-sys-typescale-*-tracking)`',
'app-css/app.css:11 literal easing in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`', 'app-css/app.css:11 literal easing in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
'app-css/app.css:11 literal duration in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-…-duration)`, paired with its easing', 'app-css/app.css:11 literal duration in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
"app-css/app.css:21 media query width `700px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)", "app-css/app.css:34 media query width `700px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)",
]); ]);
}); });
@@ -18,6 +18,19 @@
border: 0; border: 0;
} }
/* white in a comment is ignored, and so is #fff */
.quiet::before {
content: "white";
border: 1px solid white;
outline-color: #fff;
background: url(data:image/svg+xml;utf8,<svg fill='black'></svg>);
}
#add-button, .red-banner {
color: transparent;
fill: currentColor;
}
@media (min-width: 700px) { @media (min-width: 700px) {
.card { .card {
display: flex; display: flex;
@@ -0,0 +1,2 @@
<p class="text-meta text-quiet border-divider"></p>
<span class="text-on-primary-container bg-background text-current md-ink-variant"></span>