Delete Tailwind's half and build the Workbench without it

Plan step 39 (parts 1-3, folded into one commit: PHP tests read the
deleted files directly, so they cannot land apart from it). Tailwind
leaves the whole stack:

- Delete resources/css/tailwind.css, tokens/theme.css and
  tokens/utilities.css. Neither token file declared an --md-sys-*
  custom property of its own (both only referenced tokens declared
  elsewhere), so nothing loses a value; the md-* interaction and text
  classes already mirror utilities.css's declarations exactly
  (foundation/interaction.css, text.css).
- npm uninstall tailwindcss @tailwindcss/vite; vite.config.js drops the
  plugin and its import; composer.json drops the tailwindcss keyword
  (no lock change — keywords are outside Composer's content hash).
- The Workbench now builds one CSS entry, workbench/resources/css/app.css
  (all.css, showcase.css and the scheme; package.css is folded in and
  removed) instead of two, used by ErrorPage::assets() and every
  browser-test probe page's raw @vite() call; the showcase's own pages
  still take their CSS from the bundle route.
- TokensTest and StylesheetsTest: the two facts theme.css and
  utilities.css carried (every scheme role becomes a colour, resolved
  on the element; md-type-* matches the type-* utilities) are asserted
  directly against the scheme and text.css now that there is no second
  copy to cross-check; StylesheetsTest gained a full-tree scan (every
  .css file under resources/css/ is reached from all.css or
  showcase.css, no exclusions left for Tailwind); the Workbench-entry
  test and every "moved out of tailwind.css" assertion updated for the
  single entry and its removal.
- DesignGuard.php's comments and the development skill's setup section
  no longer name the deleted files or a second Tailwind entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 06:49:03 +02:00
co-authored by Claude Sonnet 5
parent cfc57a9867
commit f21943746e
24 changed files with 109 additions and 1310 deletions
@@ -61,8 +61,8 @@ it('imports the stylesheet of every component its view renders', function (strin
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A component still written for Tailwind has no stylesheet of its own to import yet; its
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
@@ -90,8 +90,8 @@ it('imports the stylesheet of every component its view renders', function (strin
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A component still written for Tailwind has no stylesheet of its own to import yet; its
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
+1 -2
View File
@@ -26,8 +26,7 @@ it('draws 52px rows, 36px dense ones, from its stylesheet in the components laye
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
->toContain('[data-md-table] :where(tbody tr[aria-selected=\'true\'])')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/table.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('table.css');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/table.css';");
});
it('sorts by its column, ascending first and then flipping', function () {
+1 -2
View File
@@ -235,8 +235,7 @@ it('keeps the field\'s chrome in its own stylesheet in the components layer', fu
->toContain("@import './icon.css';")
->toContain('[data-md-field-box]')
->not->toMatch('/(?<![\\w-])\\.field\\b/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/field.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('field.css');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/field.css';");
});
it('takes a custom control marked as the field\'s control, and a hint class', function () {
@@ -136,8 +136,8 @@ it('imports the stylesheet of every component its view renders', function (strin
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A component still written for Tailwind has no stylesheet of its own to import yet; its
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
@@ -193,6 +193,8 @@ it('imports the stylesheet of every component pagination\'s views render', funct
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
@@ -128,8 +128,8 @@ it('imports the stylesheet of every component its view renders', function (strin
return $tags[1];
})
->unique()
// A component still written for Tailwind has no stylesheet of its own to import yet; its
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
-1
View File
@@ -101,7 +101,6 @@ it('divides a scrolling body from its header and actions only while content is h
// A token that reduced motion sets to zero, so there the rule simply appears.
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/modal.css';")
->and(File::get(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('dialog.css')
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
->toContain("directive('dialog-dividers'")
+1 -2
View File
@@ -114,6 +114,5 @@ it('draws the bar and the view from its stylesheet, at M3\'s widths', function (
->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {')
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {")
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('search.css');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';");
});
+1 -2
View File
@@ -151,8 +151,7 @@ it('draws the checkbox from its stylesheet, on the row the selection controls sh
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
->toContain('[data-md-selection-row]')
->toContain('@layer material.components')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/checkbox.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('selection.css');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/checkbox.css';");
});
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
-2
View File
@@ -97,7 +97,6 @@ it('lets the application\'s own error view win', function () {
it('loads the application\'s Vite entries', function () {
File::ensureDirectoryExists($this->temporary.'/build');
File::put($this->temporary.'/build/manifest.json', json_encode([
'workbench/resources/css/package.css' => ['file' => 'assets/package-probe.css', 'src' => 'workbench/resources/css/package.css', 'isEntry' => true],
'workbench/resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'workbench/resources/css/app.css', 'isEntry' => true],
'workbench/resources/js/app.js' => ['file' => 'assets/app-probe.js', 'src' => 'workbench/resources/js/app.js', 'isEntry' => true],
]));
@@ -107,7 +106,6 @@ it('loads the application\'s Vite entries', function () {
$this->get('/abort/404')
->assertNotFound()
->assertSee('build/assets/package-probe.css', false)
->assertSee('build/assets/app-probe.css', false)
->assertSee('build/assets/app-probe.js', false)
->assertDontSee('data-md-error-fallback', false);
+53 -68
View File
@@ -10,14 +10,13 @@ use NoNameWeb\LivewireMaterial\Support\Layout;
* 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), so the shape,
* Tailwind-free and breakpoint checks below run over the whole tree `all.css` reaches (foundation,
* layout and components together) rather than the foundation alone — everything but
* `tailwind.css` and `tokens/theme.css`/`tokens/utilities.css`, which stay Tailwind until plan
* step 39 and are simply never reached from `all.css`. A component's own values (colours as
* roles, shadows as elevation levels, and so on) are each group's own concern, in
* tests/Feature/Components/*StylesheetsTest.php; `bundle()`, the PHP bundler `all.css` is built
* for, is tests/Feature/StylesheetsBundleTest.php's.
* 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
* 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,
* and so on) are each group's own concern, in tests/Feature/Components/*StylesheetsTest.php;
* `bundle()`, the PHP bundler `all.css` is built for, is tests/Feature/StylesheetsBundleTest.php's.
*/
const MATERIAL_LAYER_STATEMENT = '@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;';
@@ -178,9 +177,9 @@ function foundationStylesheets(): array
/**
* Everything all.css reaches: the foundation, every layout stylesheet and every component
* stylesheet, each once. What a check runs over now that every one of them is plain CSS (plan
* step 36) — `tailwind.css` and its two token files are never in this tree, since all.css does
* not import them.
* 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.
*
* @return list<string>
*/
@@ -194,7 +193,7 @@ function stylesheetName(string $file): string
return substr($file, strlen(stylesheetPath()) + 1);
}
it('brings every foundation file and text.css into the foundation, and nothing of Tailwind', function () {
it('brings every foundation file and text.css into the foundation, and nothing else', function () {
$foundation = array_map(stylesheetName(...), foundationStylesheets());
$parts = collect(File::files(stylesheetPath('foundation')))
@@ -207,11 +206,24 @@ it('brings every foundation file and text.css into the foundation, and nothing o
->toBe(['reset.css', 'hidden.css', 'tokens.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')
// The Tailwind half: the theme, the token utilities and the components still written for it.
->and(array_values(array_intersect($foundation, ['tokens/theme.css', 'tokens/utilities.css', 'tailwind.css'])))->toBe([])
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
});
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.
$onDisk = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
->sort()
->values();
$reached = collect(allStylesheets())->map(stylesheetName(...))->push('showcase.css')->sort()->values();
expect($onDisk->all())->toBe($reached->all());
});
it('opens every stylesheet all.css reaches with a header, the layer statement and plain imports', function () {
foreach (allStylesheets() as $file) {
$name = stylesheetName($file);
@@ -548,17 +560,19 @@ it('hides an element below and from every breakpoint but compact, and nothing el
expect($actual)->toBe($expected);
});
it('gives every md-type class the declarations of its type utility', function () {
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(stylesheetPath('tokens/utilities.css')), $matches, PREG_SET_ORDER);
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);
$utilities = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
$text = File::get(stylesheetPath('text.css'));
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
expect($utilities)->toHaveCount(30);
expect($classes)->toHaveCount(30);
foreach ($utilities as $class => $declarations) {
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations, $class)
->and(array_keys($declarations))->toBe(['font', 'letter-spacing', 'font-variation-settings']);
foreach ($classes as $class => $declarations) {
expect(array_keys($declarations))->toBe(['font', 'letter-spacing', 'font-variation-settings'], $class);
}
});
@@ -611,57 +625,28 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
}
});
it('draws the interaction classes as the utilities the Tailwind components still use', function () {
$utilities = File::get(stylesheetPath('tokens/utilities.css'));
$interaction = File::get(stylesheetPath('foundation/interaction.css'));
// 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.
$normalise = fn (string $body): string => trim((string) preg_replace('/\s+/', ' ', $body));
foreach (['state-layer', 'focus-ring', 'touch-target', 'link'] as $name) {
$class = stylesheetBlock($interaction, ".md-{$name}");
expect($normalise(str_replace(
// The two deliberate differences: the data-md-* hook in place of the old one, and M3's
// 48 CSS pixels for the target where the utility reads 3rem.
['&[data-md-dragged]', 'var(--md-sys-measurement-space600)'],
['&[data-dragged]', '3rem'],
$class,
)))->toBe($normalise(stylesheetBlock($utilities, "@utility {$name}")), $name);
}
expect(stylesheetBlock($interaction, '.md-state-layer'))->toContain('&[data-md-dragged]::before')
->not->toContain('[data-dragged]')
->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);');
});
it('builds the package in a Workbench entry of its own, with the material layers between Tailwind\'s preflight and its utilities', function () {
$package = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/package.css'));
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// Both entries open by ordering the layers, so the order holds whichever the page links first:
// above preflight, whose `* { padding: 0 }` would otherwise beat a component's padding, and
// below the utilities.
foreach (['package.css' => $package, 'app.css' => $app] as $name => $css) {
expect(trim($css))->toStartWith('@layer properties, theme, base, material, components, utilities;', $name);
}
// The package's CSS never shares an entry with Tailwind: `@tailwindcss/vite` inlines the
// imports of an entry that uses Tailwind without deduplicating them, so every shared component
// stylesheet would repeat later in the cascade instead of landing once, as it does for an
// application (StylesheetsBundleTest.php pins that against a real Vite build).
expect($package)->toContain("@import '../../../resources/css/all.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).
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->toContain('tailwind')
->not->toMatch('/@(?:source|theme|utility|variant|custom-variant|apply)\b/')
->and($app)->toContain("@import 'tailwindcss'")
->toContain("@import '../../../resources/css/tailwind.css';")
->not->toContain('all.css')
->not->toContain('foundation.css')
->not->toContain('/components/')
->not->toContain('material-scheme.css')
->and(File::get(__DIR__.'/../../vite.config.js'))->toContain("'workbench/resources/css/package.css', 'workbench/resources/css/app.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/package.css',
'workbench/resources/css/app.css',
'workbench/resources/js/app.js',
]);
+15 -20
View File
@@ -37,14 +37,15 @@ function typeStyles(): array
}
/**
* The body of every `@utility type-…` block, keyed by utility name. The utilities live in
* tokens/utilities.css, apart from the tokens, so the foundation imports no Tailwind directive.
* The body of every `.md-type-…` class in text.css, keyed by class name without the `md-` prefix
* (`type-display-lg`, `type-emphasized-display-lg`, …). Plain CSS now (plan step 39 deleted the
* Tailwind utilities they used to be); text.css is theirs alone.
*
* @return array<string, string>
*/
function typeUtilities(): array
{
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(packageCss('tokens/utilities.css')), $matches, PREG_SET_ORDER);
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(packageCss('text.css')), $matches, PREG_SET_ORDER);
return array_combine(array_column($matches, 1), array_column($matches, 2));
}
@@ -84,26 +85,20 @@ it('ships M3\'s medium and high contrast levels beside the standard one', functi
}
});
it('turns every role into a Tailwind colour', function () {
$roles = array_keys(json_decode(File::get(packageCss('tokens/scheme.json')), true)['light']);
$theme = File::get(packageCss('tokens/theme.css'));
foreach ($roles as $role) {
expect($theme)->toContain("--color-{$role}: var(--md-sys-color-{$role});");
// Tailwind's theme.css used to turn every scheme role into a `--color-*` Tailwind colour, in an
// inline theme block so it resolved on the element and a nested data-theme still repainted it
// rather than reading a value computed once on :root. Both went with Tailwind (plan step 39): the
// first fact is already the scheme test above (every role a `--md-sys-color-*` custom property,
// light and dark); the second no longer has anything to hold, because no file replaces theme.css's
// alias — every package rule reads `--md-sys-color-*` directly at its point of use, so there is no
// second custom property that could be computed once on :root and go stale under a nested
// data-theme in the first place.
it('never aliases a colour role to a second custom property resolved once on :root', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())->not->toMatch('/--color-[\w-]+:\s*var\(--md-sys-color-/', $file->getRelativePathname());
}
});
it('resolves colour utilities on the element, so a nested data-theme repaints them', function () {
$theme = File::get(packageCss('tokens/theme.css'));
preg_match_all('/@theme inline \{(.*?)\}/s', $theme, $inline);
expect(implode("\n", $inline[1]))
->toContain('--color-primary: var(--md-sys-color-primary);')
->toContain('--color-body: var(--md-sys-color-on-surface-variant);')
->not->toMatch('/--[\w-]+:\s*#/');
});
it('leaves the choice of theme and of contrast to the head script, never to a media query', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())