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
+101 -104
View File
@@ -35,13 +35,10 @@ use Symfony\Component\Finder\Finder;
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
* (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
* an application bans on top with `forbidColours()`, `forbidAbsolutes()`,
* `forbidOpacityInk()` and `forbid()`. These, and the breakpoint,
* scale and colour-value checks, read a line at a time, so a class assembled at runtime
* (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason to write
* 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.
* an application bans on top with `forbidColours()` and `forbid()`. These, and the
* breakpoint, scale and colour-value checks, read a line at a time, so a class assembled
* at runtime (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason
* to write class names out whole.
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
* ->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. */
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
* 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',
];
/** 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 = [
'on-surface' => 'md-ink',
'on-surface-variant' => 'md-ink-variant',
@@ -169,6 +182,12 @@ class DesignGuard
'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`. */
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> */
protected array $forbiddenColours = [];
protected bool $forbiddenAbsolutes = false;
protected bool $forbiddenOpacityInk = false;
protected ?string $cssEntry = null;
/** @var array<string, true>|null */
@@ -235,35 +250,6 @@ class DesignGuard
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.
*/
@@ -491,38 +477,15 @@ class DesignGuard
}
/**
* Colours written as a value rather than a role: an arbitrary one always, the two absolutes
* and opacity on ink when the application asks for them.
* Colours written as a value rather than a role: an arbitrary hex, function or mix.
*
* @return list<string>
*/
protected function colourValues(string $text): array
{
$found = [];
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
foreach ($matches[0] as $class) {
$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;
return array_map(fn (string $class): string => "arbitrary colour `{$class}`, use an M3 role", $matches[0]);
}
/**
@@ -585,23 +548,35 @@ class DesignGuard
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 "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)';
return $this->colourUtilityHint($token);
}
/**
* 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) {
return "Tailwind line role `{$token}` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility";
}
$utility = $m['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 "Tailwind surface role `{$token}` compiles to nothing — use `<x-surface level=\"{$m['role']}\">`";
}
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) {
return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS";
}
return null;
return match (true) {
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)",
$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",
$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).'">`',
default => "{$dead} — use `var(--md-sys-color-{$role})` in your own CSS",
};
}
/**
@@ -617,7 +592,7 @@ class DesignGuard
$violations = [];
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);
foreach ([
@@ -633,25 +608,43 @@ class DesignGuard
}
/**
* Every literal colour in `$css`: a hex code, a colour function, or a named colour other than
* `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) —
* which is what keeps a selector like `.red-banner` from matching.
* Every literal colour in a declaration's value: a hex code, a colour function, or a named
* colour other than `transparent`/`currentColor`, anywhere in the value (`border: 1px solid
* 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}>
*/
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(
fn (array $match): array => [
substr_count(substr($css, 0, $match[1]), "\n") + 1,
"literal colour `{$match[0]}` — use `var(--md-sys-color-*)`",
],
$matches[0],
);
foreach ($matches[0] as [$colour, $at]) {
$found[] = [
substr_count(substr($css, 0, $offset + $at), "\n") + 1,
"literal colour `{$colour}` — use `var(--md-sys-color-*)`",
];
}
}
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(
'/\/\*.*?\*\//s',
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
'/\/\*.*?\*\/|"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'|(?<=url\()[^)\'"]*(?=\))/is',
fn (array $match): string => str_starts_with($match[0], '/*')
? str_repeat("\n", substr_count($match[0], "\n"))
: (string) preg_replace('/[^\n]/', ' ', $match[0]),
$css,
);
}
@@ -1097,7 +1094,7 @@ class DesignGuard
$classes = [];
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);