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
+55 -52
View File
@@ -5,14 +5,13 @@ use NoNameWeb\LivewireMaterial\Support\Layout;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
* one opens with the same layer statement, so the order holds whichever file a bundler reaches
* first; then plain imports (an `@import` cannot sit inside a layer block, and `layer()` on it
* would nest the layer twice); then its own `@layer material.<x>` blocks. The only rules outside
* a layer are foundation/hidden.css's two `!important` ones.
* The package's stylesheets are plain CSS in `material.*` layers. Every one opens with the same
* layer statement, so the order holds whichever file a bundler reaches first; then plain imports
* (an `@import` cannot sit inside a layer block, and `layer()` on it would nest the layer twice);
* then its own `@layer material.<x>` blocks. The only rules outside a layer are
* foundation/hidden.css's two `!important` ones.
*
* Every package component and layout stylesheet is plain CSS now (plan step 36), and Tailwind
* itself is gone from the package (plan step 39), so the shape, Tailwind-free and breakpoint checks
* Every package component and layout stylesheet is plain CSS, so the shape and breakpoint checks
* below run over the whole tree `all.css` reaches (foundation, layout and components together)
* rather than the foundation alone, and — one file it does not reach — `showcase.css`, checked on
* its own further down. A component's own values (colours as roles, shadows as elevation levels,
@@ -64,7 +63,7 @@ function stylesheetImports(string $file): array
/**
* A stylesheet and every stylesheet it reaches through `@import`, each once, in the order a
* bundler meets them. `tailwindcss` itself is a package, not a file, and is left out.
* bundler meets them. An import that is not relative (a package name, a URL) is left out.
*
* @return list<string>
*/
@@ -105,9 +104,7 @@ function foundationStylesheets(): array
/**
* Everything all.css reaches: the foundation, every layout stylesheet and every component
* stylesheet, each once. Every one of them is plain CSS (plan step 36), and Tailwind itself is
* gone from the package (plan step 39) — `tailwindcss`, `tailwind.css` and its two token files no
* longer exist, so there is nothing left for this tree to exclude.
* stylesheet, each once. Every one of them is plain CSS.
*
* @return list<string>
*/
@@ -131,8 +128,7 @@ it('brings every foundation file and text.css into the foundation, and nothing e
expect($parts->diff($foundation)->values()->all())->toBe([])
->and(array_map(fn (string $import): string => basename($import), stylesheetImports(stylesheetPath('foundation.css'))))
// In the order of their layers, the rules outside every layer beside the reset — the seven
// token files imported directly (plan step 33: foundation/tokens.css held only these
// imports, and only foundation.css reached it, so it was folded in here).
// token files imported directly.
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.css', 'base.css', 'interaction.css', 'text.css'])
->and($foundation)
->toContain('tokens/scheme.css', 'tokens/shape.css', 'tokens/elevation.css', 'tokens/motion.css', 'tokens/type.css', 'tokens/state.css', 'tokens/spacing.css', 'tokens/font.css')
@@ -140,8 +136,8 @@ it('brings every foundation file and text.css into the foundation, and nothing e
});
it('paints the page in surface and on-surface, in the brand typeface, smoothed in grayscale', function () {
// An application drops Tailwind's `antialiased` with nothing to replace it: the showcase and the
// error pages, which set it themselves before, read it from here too.
// An application gets it with no class of its own needed: the showcase and the error pages,
// which set it themselves before, read it from here too.
$base = (string) preg_replace('~/\*.*?\*/~s', '', (string) file_get_contents(stylesheetPath('foundation/base.css')));
expect($base)->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);\s*color: var\(--md-sys-color-on-surface\);\s*font-family: var\(--md-ref-typeface-brand\);\s*-webkit-font-smoothing: antialiased;\s*-moz-osx-font-smoothing: grayscale;\s*\}/')
@@ -163,9 +159,9 @@ it('names the monospace faces once, as a typeface token the reset, the field and
});
it('leaves no stylesheet under resources/css/ outside all.css or showcase.css', function () {
// The full tree now that Tailwind is gone (plan step 39): every .css file on disk, not just the
// ones a bundler happens to reach — a stray or orphaned file would show up here even though
// nothing imports it, which the checks above (walking @import from all.css) cannot catch.
// The full tree: every .css file on disk, not just the ones a bundler happens to reach — a
// stray or orphaned file would show up here even though nothing imports it, which the checks
// above (walking @import from all.css) cannot catch.
$onDisk = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
@@ -257,15 +253,7 @@ it('defines x-cloak once, reached from both the foundation and all.css', functio
->and(allStylesheets())->toContain(stylesheetPath('foundation/hidden.css'));
});
it('writes no Tailwind directive anywhere all.css reaches', function () {
foreach (allStylesheets() as $file) {
expect(ComponentStylesheet::withoutComments(File::get($file)))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', stylesheetName($file))
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', stylesheetName($file));
}
});
// No Tailwind-shaped class anywhere the package ships or tests itself is
// A class no stylesheet declares, anywhere the package ships or tests itself, is
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
// either.
@@ -309,6 +297,39 @@ it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, i
}
});
it('writes every matchMedia width in resources/js at an M3 breakpoint', function () {
// The one check BreakpointsTest.php (deleted) covered that nothing else did: a script that
// widens or narrows window.matchMedia() by hand, at a width M3 does not know, rather than
// asking resources/js/breakpoints.js for the number. Isolated the same way that test isolated
// a media query's own condition — only the text inside matchMedia()'s call, up to its first
// closing quote or backtick — so a real width sitting elsewhere in the file, in a plain
// declaration rather than a query condition (a `min(40rem, 70dvh)`-style cap; none exists in
// resources/js today), is never mistaken for a breakpoint.
$files = collect(File::allFiles(__DIR__.'/../../resources/js'))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'js');
expect($files)->not->toBeEmpty();
foreach ($files as $file) {
preg_match_all('/matchMedia\(\s*[\'"`][^\'"`]*/', $file->getContents(), $conditions);
foreach ($conditions[0] as $condition) {
preg_match_all('/(\d*\.?\d+)(rem|px)/i', $condition, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
// breakpoints.js itself never writes a literal number — from() and upTo() build
// the string from the shared table (`${width(name)}px`) — so today's tree has none
// to check; this only guards whichever file writes one by hand next.
$allowed = strtolower($unit) === 'px' ? ['600', '840', '1200', '1600'] : ['37.5', '52.5', '75', '100'];
expect(in_array($number, $allowed, true))->toBeTrue(
$file->getRelativePathname().': '.trim($condition).' is not at an M3 breakpoint'
);
}
}
}
});
it('declares M3\'s spacing scale as the reference gives it', function () {
preg_match_all(
'/^\|\s*\**space(\d+)\**\s*\|\s*\**([\d.]+)×\**\s*\|\s*\**(\d+)dp/m',
@@ -526,10 +547,10 @@ it('imports, from every layout stylesheet, the stylesheet of each component its
expect($checked)->toBeGreaterThan(0);
});
// Every layout stylesheet's header, layer statement and plain-imports shape, and its freedom from
// Tailwind and from a media query off an M3 breakpoint, are covered by the two all.css-wide tests
// above (allStylesheets() reaches every layout file too) — a dedicated layout-only version of
// either would only repeat that same check on a subset already checked.
// Every layout stylesheet's header, layer statement and plain-imports shape, and its media
// queries off an M3 breakpoint, are covered by the two all.css-wide tests above (allStylesheets()
// reaches every layout file too) — a dedicated layout-only version of either would only repeat
// that same check on a subset already checked.
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
@@ -607,10 +628,6 @@ it('hides an element below and from every breakpoint but compact, and nothing el
});
it('gives every md-type class exactly font, letter-spacing and font-variation-settings', function () {
// Tailwind's tokens/utilities.css used to declare the same 30 styles as `type-*` utility
// blocks, and this test proved text.css's `.md-type-*` classes matched them; deleted with
// Tailwind (plan step 39), text.css is the only copy left, so the shape it gives each class is
// asserted directly rather than cross-checked against a second copy.
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
@@ -672,27 +689,16 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
}
});
// A test used to prove foundation/interaction.css's `.md-*` classes matched tokens/utilities.css's
// utility blocks byte for byte apart from two deliberate differences, because the components
// still written in Tailwind read the utilities while every other component read the classes.
// Deleted with Tailwind (plan step 39): every component reads the classes now, there is no second
// copy left to drift from, and interaction.css's own StylesheetsTest coverage above (the layer it
// writes into, no Tailwind directive) already holds it to the same shape every other foundation
// file keeps to.
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
it('opens the Workbench\'s one CSS entry with the layer statement, then all.css, showcase.css and the scheme', function () {
$app = ComponentStylesheet::withoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// Opens by ordering the layers, so the order holds whichever the page links first — an
// application with Tailwind of its own still opens its own entry the same way (its header).
// Opens by ordering the layers, so the order holds whichever the page links first.
expect(trim($app))->toStartWith(MATERIAL_LAYER_STATEMENT)
->toContain("@import '../../../resources/css/all.css';")
->toContain("@import '../../../resources/css/showcase.css';")
->toContain("@import './material-scheme.css';")
->not->toMatch('/@(?:tailwind|source|theme|utility|variant|custom-variant|apply|config|plugin|reference)\b/')
->and(File::get(__DIR__.'/../../vite.config.js'))
->toContain("input: ['workbench/resources/css/app.css', 'workbench/resources/js/app.js']")
->not->toContain('tailwindcss')
->and(config('livewire-material.showcase.vite'))->toBe([
'workbench/resources/css/app.css',
'workbench/resources/js/app.js',
@@ -705,10 +711,7 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($css));
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement')
->and(ComponentStylesheet::withoutComments($css))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement');
$blocks = false;
$unlayered = [];