Retarget forbidColours() at where 2.0.0 writes a role

Plan step 41 review (the user, 2026-09-15): an application without Tailwind
names a role through its --md-sys-color-* custom property (CSS, an inline
style, a script), an md-ink-* class, or a component's color/tone prop, not
a bg-tertiary utility. forbidColours() keeps its name and signature and
reports each of those, the role's on- and container roles included;
a Tailwind leftover stays family (i)'s single report. ReStride's
forbidColours(['tertiary', 'primary-container']) keeps its meaning.

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:35:06 +02:00
co-authored by Claude Opus 5
parent 6dc996afdf
commit d5972b8ddb
6 changed files with 126 additions and 13 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ it('uses only what compiles', function () {
}); });
``` ```
The guard fails on Tailwind palette colours, unknown Material Symbol names and Blade directives written inside component tags. It also fails on every Tailwind utility or variant still sitting in a view, PHP or JS file — a breakpoint prefix, a radius, shadow, type size/weight/leading/tracking, easing or duration, a flex/grid/spacing/sizing/display utility, a text-layout utility, an M3 role utility (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary `[…]` value — none of which compile any more, since the application carries no Tailwind: every line names its 2.0.0 replacement, a layout component and prop (`flex gap-4``<x-row gap="space200">`), an `md-*` class (`truncate``md-truncate`), or a token for the application's own CSS (`rounded-lg``var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt, and so is every `md-*` class. The guard fails on Tailwind palette colours, unknown Material Symbol names and Blade directives written inside component tags. `forbidColours([...])` names roles the application leaves out, and fails wherever one is still written: its `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. It also fails on every Tailwind utility or variant still sitting in a view, PHP or JS file — a breakpoint prefix, a radius, shadow, type size/weight/leading/tracking, easing or duration, a flex/grid/spacing/sizing/display utility, a text-layout utility, an M3 role utility (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary `[…]` value — none of which compile any more, since the application carries no Tailwind: every line names its 2.0.0 replacement, a layout component and prop (`flex gap-4``<x-row gap="space200">`), an `md-*` class (`truncate``md-truncate`), or a token for the application's own CSS (`rounded-lg``var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt, and so is every `md-*` class.
`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.
@@ -1117,7 +1117,7 @@ it('uses only what compiles', function () {
}); });
``` ```
It fails on Tailwind palette colours, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each. It fails on Tailwind palette colours, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each. `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop.
It also fails on every Tailwind utility or variant, which compile to nothing in a Tailwind-free application, each with its 2.0.0 replacement — a layout component and prop, 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: It also fails on every Tailwind utility or variant, which compile to nothing in a Tailwind-free application, each with its 2.0.0 replacement — a layout component and prop, 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:
+85 -7
View File
@@ -239,7 +239,11 @@ class DesignGuard
/** /**
* Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container']. * Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container'].
* Their on-roles and containers are forbidden with them. * Their on-roles and containers are forbidden with them, wherever 2.0.0 lets an application
* write one: the `--md-sys-color-*` custom property (a `var()` in its CSS, an inline `style`, a
* script reading it), the `md-ink-*` class text.css has for it, and a component's `color` or
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`). A Tailwind `bg-tertiary` left behind
* is family (i)'s to report, as every colour utility is.
* *
* @param list<string> $roles * @param list<string> $roles
*/ */
@@ -325,6 +329,10 @@ class DesignGuard
$violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile"; $violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile";
} }
foreach ($this->forbiddenRoleProps($contents) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
if ($this->cssEntry !== null) { if ($this->cssEntry !== null) {
foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) { foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}"; $violations[] = "{$where}:{$line} {$what}";
@@ -341,7 +349,7 @@ class DesignGuard
} }
} }
foreach ($this->offTheTokens($text) as $what) { foreach ([...$this->offTheTokens($text), ...$this->forbiddenRoles($text)] as $what) {
$violations[] = "{$where}:{$line} {$what}"; $violations[] = "{$where}:{$line} {$what}";
} }
@@ -376,14 +384,77 @@ class DesignGuard
protected function colourPattern(): string protected function colourPattern(): string
{ {
$names = [self::PALETTE]; return '/(?<![\w-])'.self::UTILITY.'-'.self::PALETTE.'(?![\w-])/';
}
/**
* Every place one line names a role `forbidColours()` left out: its `--md-sys-color-*` custom
* property, or the `md-ink-*` class text.css draws it with.
*
* @return list<string>
*/
protected function forbiddenRoles(string $text): array
{
$found = [];
foreach ($this->forbiddenColours as $role) { foreach ($this->forbiddenColours as $role) {
$role = preg_quote($role, '/'); $names = ['--md-sys-color-(?:on-)?'.preg_quote($role, '/').'(?:-container)?'];
$names[] = "(?:on-)?{$role}(?:-container)?";
foreach ([$role, "on-{$role}", "{$role}-container", "on-{$role}-container"] as $variant) {
if (isset(self::INK_ROLE[$variant])) {
$names[] = preg_quote(self::INK_ROLE[$variant], '/');
}
}
preg_match_all('/(?<![\w-])(?:'.implode('|', $names).')(?![\w-])/', $text, $matches);
foreach ($matches[0] as $written) {
$found[] = "`{$written}`: role `{$role}` is not part of this application's palette";
}
} }
return '/(?<![\w-])'.self::UTILITY.'-(?:'.implode('|', $names).')(?![\w-])/'; return $found;
}
/**
* Every component `color` or `tone` prop in `$contents` naming a role `forbidColours()` left
* out, literal (`color="tertiary"`) or any string a bound one can take
* (`:tone="$failed ? 'error' : 'info'"`).
*
* @return list<array{0: int, 1: string}>
*/
protected function forbiddenRoleProps(string $contents): array
{
if ($this->forbiddenColours === []) {
return [];
}
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($tags as $tag) {
preg_match_all('/\s(?<bound>:?)(?:color|tone)="(?<value>[^"]*)"/', $tag[1][0], $props, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
foreach ($props as $prop) {
$values = $prop['bound'][0] === ':'
? (preg_match_all("/'([^']*)'/", $prop['value'][0], $strings) ? $strings[1] : [])
: [$prop['value'][0]];
foreach ($values as $value) {
foreach ($this->forbiddenColours as $role) {
if (preg_match('/^(?:on-)?'.preg_quote($role, '/').'(?:-container)?$/', $value) === 1) {
$found[] = [
substr_count(substr($contents, 0, $tag[1][1] + $prop[0][1]), "\n") + 1,
'`'.trim($prop[0][0])."`: role `{$role}` is not part of this application's palette",
];
}
}
}
}
}
return $found;
} }
/** /**
@@ -592,9 +663,16 @@ class DesignGuard
$violations = []; $violations = [];
foreach ($this->applicationStylesheets() as $file) { foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutTokenFunctions($this->maskedCss((string) file_get_contents($file))); $masked = $this->maskedCss((string) file_get_contents($file));
$css = $this->withoutTokenFunctions($masked);
$where = $this->relative($file); $where = $this->relative($file);
foreach (explode("\n", $masked) as $index => $text) {
foreach ($this->forbiddenRoles($text) as $what) {
$violations[] = "{$where}:".($index + 1)." {$what}";
}
}
foreach ([ foreach ([
...$this->literalColours($css), ...$this->literalColours($css),
...$this->literalDeclarations($css), ...$this->literalDeclarations($css),
+22 -4
View File
@@ -278,12 +278,30 @@ it('reports an arbitrary [] value once, whatever utility it modifies, and strips
]); ]);
}); });
it('bans the roles an application leaves out', function () { it('bans the roles an application leaves out, wherever 2.0.0 writes one', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views')) $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/forbidden-colours'))
->forbidColours(['tertiary']) ->forbidColours(['tertiary', 'primary-container', 'error'])
->violations()); ->violations());
expect($violations)->toContain('views/page.blade.php:6 colour the theme does not declare `text-tertiary`'); expect($violations)->toBe([
'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
"forbidden-colours/page.blade.php:1 `color=\"tertiary\"`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:2 `:tone=\"'tertiary'\"`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:3 `:color=\"\$failed ? 'error' : 'info'\"`: role `error` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `--md-sys-color-on-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `md-ink-error`: role `error` is not part of this application's palette",
"forbidden-colours/page.blade.php:7 `--md-sys-color-primary-container`: role `primary-container` is not part of this application's palette",
"forbidden-colours/app.css:3 `--md-sys-color-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/app.css:4 `--md-sys-color-on-primary-container`: role `primary-container` is not part of this application's palette",
]);
});
it('leaves every role alone until an application forbids one', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/forbidden-colours'))->violations());
expect($violations)->toBe([
'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
]);
}); });
it('bans any further pattern', function () { it('bans any further pattern', function () {
@@ -0,0 +1,9 @@
/* var(--md-sys-color-tertiary) in a comment is ignored */
.hero {
background: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-primary-container);
}
.fine {
color: var(--md-sys-color-primary);
}
@@ -0,0 +1,8 @@
<x-button label="Save" color="tertiary" />
<x-badge :tone="'tertiary'" />
<x-alert :color="$failed ? 'error' : 'info'" />
<p class="md-ink-error" style="border-color: var(--md-sys-color-on-tertiary-container)">Failed</p>
<div class="bg-tertiary"></div>
<x-button color="primary" label="Allowed" />
<span style="background: var(--md-sys-color-primary-container)"></span>
<span style="background: var(--md-sys-color-tertiary-fixed)">A fixed role is another role</span>