Leave a Markdown mail component's classes to the mail theme in the design guard

A mail component is drawn by the mail theme, not by the application's
CSS entry, and the theme's classes share Tailwind's names: ReStride's
`x-mail::sessions` wraps its table in `<div class="table">`, the class
the package's own mail theme styles, and the guard reported it as
Tailwind's `display: table`. A view under a path `mail.markdown.paths`
names (Laravel's `resources/views/vendor/mail` by default) now skips
family (i) and the breakpoint, scale, palette and colour-value checks;
its icon names, the directives in its component tags, forbidColours()
and forbid() are still read. A mail theme stylesheet under that path,
which has to write literal values, is left out of check (iii) and of
the exempt classes, so its `.table` no longer hides `table` in the
application's other views either. The guard's docblock, the README and
the skill's "Testing the design" say so; a fixture test reads the same
mail view and theme with and without the path configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 04:18:17 +02:00
co-authored by Claude Opus 5
parent 7a95449c82
commit 0641071a06
8 changed files with 103 additions and 7 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ it('uses only what compiles', function () {
});
```
The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): 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. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags.
The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): 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. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt classes.
`missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or `<x-livewire-material::…>`) and `->links()`, and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals. `forbidColours([...])` names roles the application leaves out, and fails wherever one (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. `forbid($pattern, $reason)` adds any pattern of your own, line by line.
+7
View File
@@ -9,6 +9,13 @@
it in the layout, `toggle()` opens and closes the modal rail (`show()`, `hide()`) instead of
changing a choice nothing draws there. A shortcut that pressed the rail's menu button to get this
can call `$store.rail.toggle()` again.
- **`DesignGuard`** no longer reads a Markdown mail component's classes as Tailwind: a view under
`mail.markdown.paths` (`resources/views/vendor/mail` by default) is drawn by the mail theme, whose
`table`, `button` and `panel` share Tailwind's names, so only its icon names, the directives in
its component tags and the application's own bans are checked there. A mail theme stylesheet
under that path is no longer checked for literal values, and its classes no longer exempt the
same names in the application's other views. A filter an application wrote around such a
finding can go.
- **`DesignGuard`** reports a colour utility on a colour the application's own Tailwind theme
named (`bg-brand`, `text-sport-run`, `from-brand`, `border-l-zone-4`, `bg-brand/8`) in a class
list, with `var(--…)` of the application's own as its replacement, unless the application's
@@ -1207,6 +1207,8 @@ It reads every path it is given and fails with `path:line` on unknown symbol nam
In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine.
Markdown mail components — whatever lies under `config('mail.markdown.paths')`, `resources/views/vendor/mail` by default — are drawn by the mail theme, not by your CSS entry, and its classes (`table`, `button`, `panel`, `break-all`) share Tailwind's names. So the guard skips the class checks there (it still reads their icon names, the directives in their component tags and your own bans), and a mail theme stylesheet under that path, which has to write literal values, is neither checked for literals nor a source of exempt classes.
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs, even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `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. `forbid($pattern, $reason)` adds a pattern of your own.
## Conventions
+51 -6
View File
@@ -47,6 +47,15 @@ use Symfony\Component\Finder\Finder;
* at runtime (`'text-'.$tone`) or hidden in a comment stays invisible the same reason
* to write class names out whole.
*
* A Markdown mail component a view under a path `mail.markdown.paths` names, Laravel's
* `resources/views/vendor/mail` by default is drawn by the mail theme, not by the application's
* CSS entry, and the theme's own classes (`table`, `button`, `panel`, `break-all`) share a
* Tailwind utility's name. So family (i) and the breakpoint, scale, palette and colour-value
* checks skip those views, while their icon names, the directives in their component tags and
* the application's own bans are still read; and a stylesheet under that path, a mail theme that
* has to write literal values because mail clients read no custom property, is neither check
* (iii)'s nor a source of exempt classes.
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
* ->missingStylesheets(resource_path('css/app.css'))
* ->forbidColours(['tertiary'])
@@ -385,6 +394,8 @@ class DesignGuard
}
}
$mailPaths = $this->mailComponentPaths();
foreach ($this->files() as $file) {
if (str_ends_with($file->getFilename(), '.css')) {
continue; // A directly-scanned CSS file is check (iii)'s alone; these checks read Blade, PHP and JS.
@@ -393,12 +404,13 @@ class DesignGuard
$contents = (string) file_get_contents($file->getPathname());
$where = $this->relative($file->getPathname());
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
$readsClasses = ! $this->isUnder($file->getPathname(), $mailPaths);
if ($isBlade) {
$contents = $this->withoutBladeComments($contents);
}
if (str_ends_with($file->getFilename(), '.php')) {
if ($readsClasses && str_ends_with($file->getFilename(), '.php')) {
foreach ($this->literalClasses($contents) as [$line, $class]) {
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
$violations[] = "{$where}:{$line} {$hint}";
@@ -433,13 +445,13 @@ class DesignGuard
foreach (explode("\n", $contents) as $index => $text) {
$line = $index + 1;
if (preg_match_all($this->colourPattern(), $text, $matches)) {
if ($readsClasses && preg_match_all($this->colourPattern(), $text, $matches)) {
foreach ($matches[0] as $class) {
$violations[] = "{$where}:{$line} Tailwind palette colour `{$class}` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS";
}
}
foreach ([...$this->offTheTokens($text), ...$this->forbiddenRoles($text)] as $what) {
foreach ([...($readsClasses ? $this->offTheTokens($text) : []), ...$this->forbiddenRoles($text)] as $what) {
$violations[] = "{$where}:{$line} {$what}";
}
@@ -1546,8 +1558,9 @@ class DesignGuard
/**
* Every `.css` file this guard reads for check (iii): the ones `scan()`'s paths hold, outside
* the package, the generated `material-scheme.css` excluded. The CSS entry's imports are not
* followed here a vendor stylesheet it pulls in is not the application's to put on tokens.
* the package, the generated `material-scheme.css` and a mail theme under a mail component path
* excluded. The CSS entry's imports are not followed here a vendor stylesheet it pulls in is
* not the application's to put on tokens.
*
* @return list<string>
*/
@@ -1571,12 +1584,44 @@ class DesignGuard
}
}
$mailPaths = $this->mailComponentPaths();
return array_values(array_unique(array_filter(
$files,
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file),
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file) && ! $this->isUnder($file, $mailPaths),
)));
}
/**
* The Markdown mail component paths Laravel renders mail from (`mail.markdown.paths`), as real
* paths; one that does not exist is left out. See the class header.
*
* @return list<string>
*/
protected function mailComponentPaths(): array
{
return array_values(array_filter(array_map(
fn (mixed $path): string => is_string($path) ? (string) realpath($path) : '',
(array) config('mail.markdown.paths', []),
)));
}
/**
* @param list<string> $directories Real paths.
*/
protected function isUnder(string $file, array $directories): bool
{
$file = realpath($file) ?: $file;
foreach ($directories as $directory) {
if (str_starts_with($file, rtrim($directory, '/').'/')) {
return true;
}
}
return false;
}
/**
* Every class an application's own stylesheets select on the scanned ones and whatever the
* CSS entry imports outside the package check (i)'s exemption list, so a class it defines is
+29
View File
@@ -336,6 +336,35 @@ it('reports a colour utility on the application\'s own theme colour, and a Tailw
]);
});
it('leaves a Markdown mail component\'s classes and its theme to the mail theme, but still reads its icons and directives', function () {
$mail = realpath(GUARD_FIXTURES.'/mail/views');
$table = 'Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS';
$icon = 'mail/views/vendor/mail/html/sessions.blade.php:7 unknown Material Symbol `not_a_symbol`';
$directive = 'mail/views/vendor/mail/html/sessions.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`';
config(['mail.markdown.paths' => [$mail.'/vendor/mail']]);
// The theme's `.table` exempts nothing outside the mail components, and its literals are its own.
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
"mail/views/page.blade.php:1 {$table}",
$icon,
$directive,
]);
// Outside a mail component path the same files read as the application's own: the theme's
// classes as Tailwind, its literals as check (iii)'s, and its `.table` exempting `table`.
config(['mail.markdown.paths' => []]);
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
'mail/views/vendor/mail/html/sessions.blade.php:3 value outside the M3 scale `text-sm` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'mail/views/vendor/mail/html/sessions.blade.php:4 Tailwind text utility `break-all` compiles to nothing — write the rule in your own CSS',
$icon,
$directive,
'mail/views/vendor/mail/html/themes/default.css:2 literal colour `#3d4852` — use `var(--md-sys-color-*)`',
'mail/views/vendor/mail/html/themes/default.css:3 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
]);
});
it('reports an arbitrary [] value once, whatever utility it modifies, and strips a trailing !', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/arbitrary.blade.php'))->violations());
@@ -0,0 +1 @@
<div class="table"></div>
@@ -0,0 +1,8 @@
@props(['sessions', 'url'])
<div class="table">
<table class="sessions text-sm" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr><td class="break-all">{{ $sessions }}</td></tr>
</table>
</div>
<x-mail::button :url="$url" icon="not_a_symbol">Open the week</x-mail::button>
<x-mail::panel @class(['wide'])>A panel</x-mail::panel>
@@ -0,0 +1,4 @@
.table th {
color: #3d4852;
font-size: 14px;
}