Files
livewire-material/tests/Feature/Components/InputStylesheetsTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 f21943746e 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
2026-09-15 06:49:03 +02:00

209 lines
10 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The inputs, selection and data components, rewritten without Tailwind (plan step 36): each view
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), and each has a stylesheet in
* `material.components` that imports the stylesheets of the components its view renders, writes
* its values from the tokens and its breakpoints as px range queries, and is imported from the
* "Inputs, selection and data" block of all.css.
*
* `pagination` is not in this dataset: its four views live in `resources/views/pagination/**`, not
* `resources/views/components/`, so the two checks that read a single `resources/views/components/
* {name}.blade.php` file do not apply to it. It gets the three checks that do not depend on that
* path below, and its own small versions of the other two.
*/
function inputComponents(): array
{
return [
'form',
'field',
'input',
'password',
'textarea',
'select',
'file',
'checkbox',
'radio',
'toggle',
'chip',
'chip-set',
'choices',
'slider',
'search',
'table',
'sort-header',
'datepicker',
'timepicker',
];
}
dataset('input components', inputComponents());
/**
* The checks that read a stylesheet alone also run on the one no view renders by itself:
* selection.css, what the checkbox, radio and switch share (its own file header names them), which
* each of their stylesheets imports too. Nothing renders it directly, so without this its shape
* and token rules would go unchecked, the way navigation-item.css did before
* NavigationStylesheetsTest.php picked it up.
*/
dataset('input stylesheets', [...inputComponents(), 'selection']);
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. The one exception is a viewport *height* in a query keyed on `orientation`: the time
* picker lies its dial down by device orientation and height, which M3 explicitly does not make a
* breakpoint (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour),
* so that height may be any px. A width, in any query, is still a breakpoint. `rem` and `em` would
* grow with the reader's text size, which neither should.
*/
function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
$orientation = preg_match('/\(\s*orientation\s*:/', $query) === 1;
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
$height = preg_match('/(?:^|[\s:<>=])(?:min-|max-)?height\b/', $feature) === 1 && ! str_contains($feature, 'width');
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
if (! ($orientation && $height)) {
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
}
}
}
}
}
/**
* Colours are roles, shadows are elevation levels, type is a type style, motion is a spring — and
* breakpoints are px, at one of M3's four (an orientation query's height aside; see
* assertBreakpointsInPx()).
*
* One named exception, in field.css alone: its autofill workaround (`transition: background-color
* 604800s, color 604800s`) delays Chrome's autofill background for a week, which is not a spring and
* is not meant to be one — a `var(--md-sys-motion-*)` duration there would visibly repaint on load.
*/
function assertTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
if ($label === 'field.css') {
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
}
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour
// role (or a role mixed toward transparent, a disabled ring) — a day's or a year's "current"
// outline, a focused time field's edge. Never a hand-made shadow.
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
assertBreakpointsInPx($css, $label);
}
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('input stylesheets');
it('imports the stylesheet of every component its view renders', function (string $name) {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.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()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
})->with('input components');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
})->with('input components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('input stylesheets');
it('is imported from the inputs, selection and data block of all.css', function (string $name) {
expect(allCssBlock('Inputs, selection and data'))->toContain("@import './components/{$name}.css';");
})->with('input components');
// ---- pagination: its views live outside resources/views/components/ --------------------------
it('draws pagination from a stylesheet shaped like every package stylesheet', function () {
$css = ComponentStylesheet::read('pagination');
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path('pagination')).'/'.$import))->toBeTrue("pagination.css imports {$import}, which does not exist");
}
});
it('takes pagination\'s values from the tokens and its breakpoints in px', function () {
assertTokensAndPxBreakpoints(ComponentStylesheet::read('pagination'), 'pagination.css');
});
it('is imported from the inputs, selection and data block of all.css for pagination', function () {
expect(allCssBlock('Inputs, selection and data'))->toContain("@import './components/pagination.css';");
});
dataset('pagination views', [
'pagination/laravel/tailwind',
'pagination/laravel/simple-tailwind',
'pagination/livewire/tailwind',
'pagination/livewire/simple-tailwind',
]);
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
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()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
})->with('pagination views');
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('pagination views');