Take Tailwind out of the package, and its detection out of the guard
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s

Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 21:07:39 +02:00
co-authored by Claude Opus 5
parent 247c596c3a
commit fb7007c976
88 changed files with 649 additions and 1521 deletions
+32 -86
View File
@@ -1,13 +1,14 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
/**
* The texts Boost copies into an application: the two guidelines every session reads, and the two
* skills. Each teaches the vocabulary an agent then writes, so none may name a class or variant
* the package's stylesheets do not define.
* skills. A fenced Blade, HTML, PHP or JS block goes through the design guard whole, so none may
* write a class no stylesheet declares; in prose, only an inline span that reads as an `md-*`
* class list is checked (`boostSpanIsClassList()`), so a name from another vocabulary — a prop, a
* role, a config key — is never held to that standard.
*/
const BOOST_TEXTS = [
'resources/boost/guidelines/core.blade.php',
@@ -16,16 +17,10 @@ const BOOST_TEXTS = [
'resources/boost/skills/material-3-design/SKILL.md',
];
/**
* The one section that names Tailwind's utilities on purpose: the design guard's table of what it
* reports, each beside its replacement.
*/
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
/**
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
* list (`bg-primary`, `medium:hidden`, `md-ink-variant`) as a `class` attribute on its own line.
* and JS into a view, fenced CSS and an inline rule into a stylesheet, and every inline span that
* reads as a class list (`md-ink-variant md-truncate`) as a `class` attribute on its own line.
* Prose, props, attributes and custom properties are left out.
*
* @return array{view: string, css: string}
@@ -35,7 +30,6 @@ function boostTextAsCode(string $text): array
$view = [];
$css = [];
$fence = null;
$skipping = false;
foreach (explode("\n", $text) as $line) {
$viewLine = '';
@@ -44,28 +38,28 @@ function boostTextAsCode(string $text): array
if (preg_match('/^```\s*([a-z]*)/', $line, $open) === 1) {
$fence = $fence === null ? ($open[1] ?: 'text') : null;
} elseif ($fence !== null) {
if (! $skipping && $fence === 'css') {
if ($fence === 'css') {
$cssLine = $line;
} elseif (! $skipping && in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
} elseif (in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
$viewLine = $line;
}
} else {
if (preg_match('/^## (.+)$/', $line, $heading) === 1) {
$skipping = in_array($heading[1], BOOST_SECTIONS_NAMING_WHAT_FAILS, true);
}
preg_match_all('/`([^`]+)`/', $line, $spans);
if (! $skipping) {
preg_match_all('/`([^`]+)`/', $line, $spans);
// A span that is a rule of its own (`.sport-run-label { … }`) declares the class
// the example beside it writes, so it goes on the stylesheet side of the pair.
[$rules, $spans] = collect($spans[1])->partition(fn (string $span): bool => preg_match('/^\s*\.[\w-]+[^{]*\{[^}]*\}\s*$/', $span) === 1);
$viewLine = collect($spans[1])
->map(fn (string $span): ?string => match (true) {
str_contains($span, '<') => $span,
boostSpanIsClassList($span) => '<div class="'.$span.'"></div>',
default => null,
})
->filter()
->implode(' ');
}
$cssLine = $rules->implode(' ');
$viewLine = $spans
->map(fn (string $span): ?string => match (true) {
str_contains($span, '<') => $span,
boostSpanIsClassList($span) => '<div class="'.$span.'"></div>',
default => null,
})
->filter()
->implode(' ');
}
$view[] = $viewLine;
@@ -76,70 +70,22 @@ function boostTextAsCode(string $text): array
}
/**
* The hyphenated names the texts also write alone in a span for something that is not a class:
* every property the package's stylesheets declare (`border-color`), every component prop
* (`placeholder-value`) and every colour role (`outline-variant`). Each is shaped like a colour
* utility on a name the guard does not know, which is what an application's own theme colour
* (`bg-brand`) looks like.
*
* @return array<string, true>
*/
function boostNamesThatAreNotClasses(): array
{
static $names = null;
if ($names !== null) {
return $names;
}
$names = [];
$root = __DIR__.'/../../resources';
foreach (File::allFiles("{$root}/css") as $file) {
preg_match_all('/(?<![\w-])([a-z][a-z-]*-[a-z-]+)\s*:\s*[^;{}]*[;}]|--md-sys-color-([a-z-]+)/', $file->getContents(), $matches);
foreach ([...$matches[1], ...$matches[2]] as $name) {
$names[$name] = true;
}
}
foreach (File::files("{$root}/views/components") as $file) {
if (preg_match('/@props\(\[(.*?)\]\)/s', $file->getContents(), $props) === 1) {
preg_match_all("/^\s*'(\w+)'/m", $props[1], $keys);
foreach ($keys[1] as $key) {
$names[Str::kebab($key)] = true;
}
}
}
return $names;
}
/**
* Whether an inline span reads as a class list. Not: a CSS declaration (`font: var(…)`), a
* command (`php artisan …`), a single bare word, which is as often a role, prop or value
* (`outline`, `link`, `hidden`) as a utility, or a single property, prop or role name
* (`border-color`, `placeholder-value`, `outline-variant`).
* Whether an inline span reads as a class list: every token in it, and there has to be at least
* one, has to be an `md-*` class — the only vocabulary this check holds prose to. A single bare
* word (`outline`, `link`, `hidden`), a property or prop name (`border-color`,
* `placeholder-value`), a colour role (`outline-variant`), a CSS function (`var(…)`) and a shell
* command (`php artisan …`) all fail that test on their own, with no list of names to keep here.
*/
function boostSpanIsClassList(string $span): bool
{
$tokens = preg_split('/\s+/', trim($span));
if (count($tokens) === 1 && (preg_match('/[-:]/', $tokens[0]) !== 1 || isset(boostNamesThatAreNotClasses()[$tokens[0]]))) {
return false;
}
if (preg_match('/^(?:php|npm|npx|composer|git)\b|\b(?:var|calc|color-mix)\(|^[a-z-]+:\s/', $span) === 1) {
return false;
}
foreach ($tokens as $token) {
if (preg_match('/^(?:--|data-|aria-|wire:|x-|@|\$)/', $token) === 1 || str_contains($token, '::')) {
return false;
}
if (preg_match('/^[a-z0-9][a-z0-9:\/.\[\]()#%_-]*$/', $token) !== 1) {
// Every class the package declares is `md-*`, so that prefix is what tells a class list
// from the prop values, slot names and config keys the prose lists the same way
// (`top-start`, `rail-header`, `livewire-material.profiles`). A class of an application's
// own that an example names is nobody's to declare here.
if (preg_match('/^(?:[a-z][\w-]*:)*md-[a-z\d-]+$/', $token) !== 1) {
return false;
}
}