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:
co-authored by
Claude Opus 5
parent
c047fefd8b
commit
6dc996afdf
+101
-104
@@ -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 40–88 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user