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
+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