Files
livewire-material/tests/Feature/StylesheetsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
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
Take Tailwind out of the package, and its detection out of the guard
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>
2026-09-17 21:07:39 +02:00

793 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Layout;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* 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, 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,
* and so on) are ComponentStylesheetsTest's own concern.
* The prebuilt bundles in resources/dist/ are tests/Feature/StylesheetsBundleTest.php's.
*/
const MATERIAL_LAYER_STATEMENT = '@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;';
function stylesheetPath(string $path = ''): string
{
return (string) realpath(ComponentStylesheet::cssPath($path));
}
/**
* The body of the first block with this prelude, at the top level or one layer block down.
*/
function stylesheetBlock(string $css, string $prelude): string
{
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
if (($item['prelude'] ?? null) === $prelude) {
return $item['body'];
}
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body'])) as $inner) {
if (($inner['prelude'] ?? null) === $prelude) {
return $inner['body'];
}
}
}
}
throw new RuntimeException("No block `{$prelude}`.");
}
/**
* The paths a stylesheet imports, as written.
*
* @return list<string>
*/
function stylesheetImports(string $file): array
{
return collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))))
->map(fn (array $item): ?string => ComponentStylesheet::importPath($item['statement'] ?? ''))
->filter()
->values()
->all();
}
/**
* A stylesheet and every stylesheet it reaches through `@import`, each once, in the order a
* bundler meets them. An import that is not relative (a package name, a URL) is left out.
*
* @return list<string>
*/
function stylesheetTree(string $entry): array
{
$files = [];
$visit = function (string $file) use (&$visit, &$files): void {
$file = (string) realpath($file);
if (in_array($file, $files, true)) {
return;
}
$files[] = $file;
foreach (stylesheetImports($file) as $import) {
if (str_starts_with($import, '.')) {
$visit(dirname($file).'/'.$import);
}
}
};
$visit($entry);
return $files;
}
/**
* The foundation: foundation.css and everything it imports.
*
* @return list<string>
*/
function foundationStylesheets(): array
{
return stylesheetTree(stylesheetPath('foundation.css'));
}
/**
* Everything all.css reaches: the foundation, every layout stylesheet and every component
* stylesheet, each once. Every one of them is plain CSS.
*
* @return list<string>
*/
function allStylesheets(): array
{
return stylesheetTree(stylesheetPath('all.css'));
}
function stylesheetName(string $file): string
{
return substr($file, strlen(stylesheetPath()) + 1);
}
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')))
->map(fn (SplFileInfo $file): string => 'foundation/'.$file->getFilename())
->push('foundation.css', 'text.css');
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.
->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')
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
});
it('paints the page in surface and on-surface, in the brand typeface, smoothed in grayscale', function () {
// 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*\}/')
->and((string) file_get_contents(stylesheetPath('showcase.css')).file_get_contents(stylesheetPath('components/error-page.css')))
->not->toContain('font-smoothing');
});
it('names the monospace faces once, as a typeface token the reset, the field and md-mono read', function () {
// Not an M3 token: M3 names a brand and a plain typeface and nothing for code.
$sources = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), 'ui-monospace'))
->map(fn (SplFileInfo $file): string => str_replace(stylesheetPath().'/', '', $file->getPathname()))
->values()
->all();
expect($sources)->toBe(['tokens/type.css'])
->and((string) file_get_contents(stylesheetPath('foundation/reset.css')))->toContain('font-family: var(--md-ref-typeface-mono);')
->and((string) file_get_contents(stylesheetPath('components/field.css')))->toContain('font-family: var(--md-ref-typeface-mono);');
});
it('leaves no stylesheet under resources/css/ outside all.css or showcase.css', function () {
// 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()))
->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);
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file)));
expect(File::get($file))->toStartWith('/*', "{$name} has no header comment")
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, "{$name} does not open with the layer statement");
$blocks = false;
foreach (array_slice($items, 1) as $item) {
if (isset($item['prelude'])) {
$blocks = true;
continue;
}
expect($blocks)->toBeFalse("{$name}: `{$item['statement']}` comes after a block")
// A plain import: a quoted path and nothing after it — no layer(), supports() or media.
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "{$name}: `{$item['statement']}` is not a plain import");
}
}
});
it('keeps every foundation rule inside its material layer, but the two that hide', function () {
// Which layer each file writes into.
$layers = [
'foundation.css' => null,
'foundation/reset.css' => 'material.reset',
'foundation/hidden.css' => null,
'foundation/base.css' => 'material.base',
'foundation/interaction.css' => 'material.base',
'text.css' => 'material.text',
'tokens/font.css' => 'material.base',
];
$unlayered = [];
$important = 0;
foreach (foundationStylesheets() as $file) {
$name = stylesheetName($file);
$layer = array_key_exists($name, $layers) ? $layers[$name] : 'material.tokens';
$important += substr_count(ComponentStylesheet::withoutComments(File::get($file)), '!important');
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))) as $item) {
if (! isset($item['prelude'])) {
continue;
}
if (! str_starts_with($item['prelude'], '@layer ')) {
$unlayered["{$name}: {$item['prelude']}"] = ComponentStylesheet::flatDeclarations($item['body']);
continue;
}
expect($item['prelude'])->toBe("@layer {$layer}", "{$name} writes into `{$item['prelude']}`")
// A layer inside the block would become material.<x>.<y>, below the rest of <x>.
->and($item['body'])->not->toContain('@layer');
}
}
expect($unlayered)->toBe([
"foundation/hidden.css: [hidden]:where(:not([hidden='until-found']))" => ['display' => 'none !important'],
'foundation/hidden.css: [x-cloak]' => ['display' => 'none !important'],
])->and($important)->toBe(2);
});
it('defines x-cloak once, reached from both the foundation and all.css', function () {
$definitions = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
->filter(fn (SplFileInfo $file): bool => str_contains(ComponentStylesheet::withoutComments($file->getContents()), '[x-cloak]'))
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
->values()
->all();
expect($definitions)->toBe(['foundation/hidden.css'])
->and(foundationStylesheets())->toContain(stylesheetPath('foundation/hidden.css'))
->and(allStylesheets())->toContain(stylesheetPath('foundation/hidden.css'));
});
// 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.
it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, in px', function () {
$queries = collect(allStylesheets())
->flatMap(function (string $file): array {
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments(File::get($file)), $matches);
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
});
// Some queries (hover, reduced motion) hold no width at all; the check below is only for the
// ones that do.
expect($queries)->not->toBeEmpty();
foreach ($queries as $query) {
$feature = substr($query, strpos($query, ': ') + 2);
// The one named exception (docs/reference/m3/components-navigation-selection-inputs.md §
// Time pickers/Behaviour, and tests/Feature/Components/ComponentStylesheetsTest.php's
// token-values test): a viewport *height* in an `orientation` query is not a breakpoint,
// since M3 does not make one of it. A width, in any query, still is.
$orientationHeight = preg_match('/\(\s*orientation\s*:/', $feature) === 1
&& preg_match('/(?:^|[\s:<>=(])(?:min-|max-)?height\b/', $feature) === 1
&& ! str_contains($feature, 'width');
// Range syntax (`width >= 840px`, `840px <= width < 1200px`), as the layout stylesheets'
// own check required before it folded into this one: never `min-width`/`max-width`.
expect($feature)->not->toMatch('/\b(?:min|max)-(?:width|height|aspect-ratio)\b/', "{$query} is not written as a range");
preg_match_all('/(\d*\.?\d+)([a-z%]*)/i', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $query);
if (! $orientationHeight) {
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
}
}
}
});
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',
File::get(__DIR__.'/../../docs/reference/m3/styles-supplement.md'),
$rows,
PREG_SET_ORDER,
);
$reference = collect($rows)->mapWithKeys(fn (array $row): array => ["--md-sys-measurement-space{$row[1]}" => "{$row[3]}px"])->all();
$tokens = ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
expect($reference)->toHaveCount(13)
->and($tokens)->toBe($reference);
// space100 is the 8dp base unit and every name is its multiplier: space125 = 1.25 × 8.
foreach ($rows as [, $name, $multiplier, $value]) {
expect((float) $name / 100 * 8)->toBe((float) $value)
->and((float) $multiplier * 8)->toBe((float) $value);
}
});
/**
* The layout components' stylesheets: all.css's own `./layout/*.css` imports, each with
* everything it in turn imports (which reaches into `components/`, for the layout files that draw
* with a component, e.g. pane.css's app bar and button).
*
* @return list<string>
*/
function layoutStylesheets(): array
{
$files = [];
foreach (stylesheetImports(stylesheetPath('all.css')) as $import) {
if (! str_starts_with($import, './layout/')) {
continue;
}
foreach (stylesheetTree(stylesheetPath($import)) as $file) {
if (! in_array($file, $files, true)) {
$files[] = $file;
}
}
}
return $files;
}
it('imports every stylesheet under components/ and layout/ directly, each exactly once', function () {
$imports = stylesheetImports(stylesheetPath('all.css'));
expect(count($imports))->toBe(count(array_unique($imports)), 'all.css imports something more than once');
$expected = collect(File::files(stylesheetPath('components')))
->map(fn (SplFileInfo $file): string => "./components/{$file->getFilename()}")
->merge(collect(File::files(stylesheetPath('layout')))->map(fn (SplFileInfo $file): string => "./layout/{$file->getFilename()}"))
->sort()
->values()
->all();
// Every file under both directories is imported directly — a shared one (navigation-item.css,
// selection.css, menu.css included) as much as a component's own — and nothing stale remains.
$actual = collect($imports)
->filter(fn (string $import): bool => str_starts_with($import, './components/') || str_starts_with($import, './layout/'))
->sort()
->values()
->all();
expect($actual)->toBe($expected);
});
it('gives every component view a stylesheet, or lists it on a short, documented exception', function () {
// theme-script renders an inline <script> only — no markup of its own for a stylesheet to
// draw. tab renders data-md-tab-panel, drawn by tabs.css, its parent <x-tabs>'s own
// stylesheet (tabs.blade.php's own header says so).
$exceptions = ['theme-script' => true, 'tab' => true];
$views = collect(File::files(__DIR__.'/../../resources/views/components'))
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'));
expect($views)->not->toBeEmpty();
foreach ($views as $name) {
if (isset($exceptions[$name])) {
continue;
}
expect(is_file(stylesheetPath("components/{$name}.css")) || is_file(stylesheetPath("layout/{$name}.css")))
->toBeTrue("{$name}.blade.php has no stylesheet, in components/ or layout/, and is not on the documented exception list");
}
foreach (array_keys($exceptions) as $name) {
expect($views->contains($name))->toBeTrue("{$name} is on the documented exception list but no longer exists as a component view");
}
});
/**
* Every component view ComponentStylesheetsTest used to check one at a time, in one dataset
* instead: every `resources/views/components/*.blade.php` backed by a `components/*.css` file of
* its own, `tabs` and `tab` folded into the one entry `imports…()` below already special-cases
* (they share tabs.css), `theme-script` (no stylesheet at all) and every layout component (the
* test above covers those) left out.
*
* @return list<string>
*/
function componentStylesheetViews(): array
{
// Plain glob(), not the File facade: dataset() below runs while the file is parsed, before
// Pest has booted the application the facade needs.
return collect(glob(__DIR__.'/../../resources/views/components/*.blade.php'))
->map(fn (string $file): string => basename($file, '.blade.php'))
->reject(fn (string $name): bool => in_array($name, ['theme-script', 'tab'], true))
->filter(fn (string $name): bool => is_file(__DIR__."/../../resources/css/components/{$name}.css"))
->values()
->all();
}
dataset('component stylesheet views', componentStylesheetViews());
/**
* The `<x-livewire-material::…>` tags a view renders, including any inside a
* `@include('livewire-material::partials.<name>', …)` it pulls in — markup a view moved out to
* resources/views/partials/<name>.blade.php (menu's filter field, the timepicker's period picker)
* still renders the same tags, just not inline where a plain regex over the view alone would see
* them.
*
* @return list<string>
*/
function renderedComponentTags(string $view): array
{
$content = File::get($view);
preg_match_all('/<x-livewire-material::([a-z-]+)/', $content, $tags);
preg_match_all('/@include\(\'livewire-material::partials\.([\w.-]+)\'/', $content, $partials);
return collect($tags[1])
->merge(collect($partials[1])->flatMap(fn (string $partial): array => renderedComponentTags(__DIR__."/../../resources/views/partials/{$partial}.blade.php")))
->all();
}
it('imports, from every component stylesheet, the stylesheet of each component its view renders', function (string $name) {
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php; every other
// component stylesheet in this dataset has exactly one view of its own name.
$views = $name === 'tabs' ? ['tabs', 'tab'] : [$name];
$rendered = collect($views)
->flatMap(fn (string $view): array => renderedComponentTags(__DIR__."/../../resources/views/components/{$view}.blade.php"))
->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(stylesheetPath("components/{$tag}.css")) && str_contains(File::get(stylesheetPath("components/{$tag}.css")), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
$imports = stylesheetImports(stylesheetPath("components/{$name}.css"));
expect(array_values(array_diff($rendered, $imports)))->toBe([]);
})->with('component stylesheet views');
it('imports a stylesheet for every layout component from all.css, each beside its view', function () {
$imports = array_values(array_filter(
stylesheetImports(stylesheetPath('all.css')),
fn (string $import): bool => str_starts_with($import, './layout/'),
));
$reached = array_map(stylesheetName(...), layoutStylesheets());
$files = collect(File::files(stylesheetPath('layout')))->map(fn (SplFileInfo $file): string => $file->getBasename('.css'));
// Two files are shared by the components rather than being one: the spacing props and the
// visibility props. Both are still imported directly by all.css, like every other layout file
// — only the "has its own component" half of the check below skips them.
$components = $files->diff(['spacing', 'visibility'])->values();
expect($components)->not->toBeEmpty()
->and($files->map(fn (string $name): string => "layout/{$name}.css")->diff($reached)->values()->all())->toBe([]);
foreach ($files as $name) {
expect(in_array("./layout/{$name}.css", $imports, true))->toBeTrue("layout/{$name}.css is not imported directly by all.css");
}
foreach ($components as $name) {
expect(File::exists(__DIR__."/../../resources/views/components/{$name}.blade.php"))->toBeTrue("layout/{$name}.css has no component");
}
});
it('imports, from every layout stylesheet, the stylesheet of each component its view renders', function () {
$checked = 0;
foreach (File::files(stylesheetPath('layout')) as $file) {
$name = $file->getBasename('.css');
$view = __DIR__."/../../resources/views/components/{$name}.blade.php";
if (! File::exists($view)) {
continue;
}
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get($view), $tags);
$imports = stylesheetImports($file->getPathname());
foreach (array_unique($tags[1]) as $tag) {
$import = match (true) {
File::exists(stylesheetPath()."/components/{$tag}.css") => "../components/{$tag}.css",
File::exists(stylesheetPath()."/layout/{$tag}.css") => "./{$tag}.css",
default => null,
};
if ($import !== null) {
$checked++;
expect(in_array($import, $imports, true))->toBeTrue("layout/{$name}.css renders <x-{$tag}> without importing {$import}");
}
}
}
expect($checked)->toBeGreaterThan(0);
});
// 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
// file draws on (pane.css: app-bar.css and button.css, for its own top app bar and back
// button) — each already checked, in material.components, by ComponentStylesheetsTest.
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
if (! str_starts_with($name, 'layout/')) {
continue;
}
$css = File::get($file);
$layer = match ($name) {
'layout/visibility.css' => 'material.visibility',
default => 'material.layout',
};
expect(ComponentStylesheet::withoutComments($css))->not->toContain('!important', $name);
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
if (! isset($item['prelude'])) {
continue;
}
expect($item['prelude'])->toBe("@layer {$layer}", "{$name} writes outside `@layer {$layer}`")
->and($item['body'])->not->toContain('@layer');
}
}
});
it('gives every spacing token a gap and a padding, and the layout components read only those', function () {
$tokens = array_map(
fn (string $property): string => substr($property, strlen('--md-sys-measurement-')),
array_keys(ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
);
expect(Layout::SPACING)->toBe($tokens);
$spacing = File::get(stylesheetPath('layout/spacing.css'));
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
foreach ($tokens as $token) {
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
->and(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
}
expect(collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($spacing, '@layer material.layout'))))->pluck('prelude')->all())
->toHaveCount(1 + 2 * count($tokens));
});
it('hides an element below and from every breakpoint but compact, and nothing else', function () {
$visibility = stylesheetBlock(File::get(stylesheetPath('layout/visibility.css')), '@layer material.visibility');
$expected = [];
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
$expected["@media (width < {$width}px)"] = "[data-md-hide-below='{$breakpoint}']";
}
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
$expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']";
}
$actual = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments($visibility)))->mapWithKeys(function (array $item): array {
$rule = ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body']))[0];
expect(ComponentStylesheet::flatDeclarations($rule['body']))->toBe(['display' => 'none']);
return [$item['prelude'] => $rule['prelude']];
})->all();
expect($actual)->toBe($expected);
});
it('gives every md-type class exactly font, letter-spacing and font-variation-settings', function () {
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])]);
expect($classes)->toHaveCount(30);
foreach ($classes as $class => $declarations) {
expect(array_keys($declarations))->toBe(['font', 'letter-spacing', 'font-variation-settings'], $class);
}
});
it('ships exactly the documented text classes, each ink an M3 role', function () {
$text = File::get(stylesheetPath('text.css'));
$styles = collect(['display', 'headline', 'title', 'body', 'label'])
->crossJoin(['lg', 'md', 'sm'])
->flatMap(fn (array $style): array => ["md-type-{$style[0]}-{$style[1]}", "md-type-emphasized-{$style[0]}-{$style[1]}"]);
$inks = [
'md-ink' => 'on-surface',
'md-ink-variant' => 'on-surface-variant',
'md-ink-quiet' => 'outline',
'md-ink-primary' => 'primary',
'md-ink-error' => 'error',
'md-ink-success' => 'success',
'md-ink-warning' => 'warning',
'md-ink-info' => 'info',
'md-ink-inverse' => 'inverse-on-surface',
];
$rest = [
'md-text-start' => ['text-align' => 'start'],
'md-text-center' => ['text-align' => 'center'],
'md-text-end' => ['text-align' => 'end'],
'md-truncate' => ['overflow' => 'hidden', 'text-overflow' => 'ellipsis', 'white-space' => 'nowrap'],
'md-line-clamp-2' => ['overflow' => 'hidden', 'display' => '-webkit-box', '-webkit-box-orient' => 'vertical', '-webkit-line-clamp' => '2'],
'md-line-clamp-3' => ['overflow' => 'hidden', 'display' => '-webkit-box', '-webkit-box-orient' => 'vertical', '-webkit-line-clamp' => '3'],
'md-nowrap' => ['white-space' => 'nowrap'],
'md-tabular' => ['font-variant-numeric' => 'tabular-nums'],
'md-mono' => ['font-family' => 'var(--md-ref-typeface-mono)'],
'md-visually-hidden' => [
'position' => 'absolute', 'width' => '1px', 'height' => '1px', 'padding' => '0', 'margin' => '-1px',
'overflow' => 'hidden', 'clip-path' => 'inset(50%)', 'white-space' => 'nowrap', 'border-width' => '0',
],
];
$classes = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($text, '@layer material.text'))))
->map(fn (array $item): string => $item['prelude'] ?? $item['statement'])
->all();
expect($classes)->toBe($styles->merge(array_keys($inks))->merge(array_keys($rest))->map(fn (string $class): string => ".{$class}")->all());
foreach ($inks as $class => $role) {
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
}
foreach ($rest as $class => $declarations) {
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
}
});
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.
expect(trim($app))->toStartWith(MATERIAL_LAYER_STATEMENT)
->toContain("@import '../../../resources/css/all.css';")
->toContain("@import '../../../resources/css/showcase.css';")
->toContain("@import './material-scheme.css';")
->and(File::get(__DIR__.'/../../vite.config.js'))
->toContain("input: ['workbench/resources/css/app.css', 'workbench/resources/js/app.js']")
->and(config('livewire-material.showcase.vite'))->toBe([
'workbench/resources/css/app.css',
'workbench/resources/js/app.js',
]);
});
it('shapes showcase.css like a package stylesheet, with its documented unlayered exceptions', function () {
$path = __DIR__.'/../../resources/css/showcase.css';
$css = File::get($path);
$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');
$blocks = false;
$unlayered = [];
foreach (array_slice($items, 1) as $item) {
if (isset($item['prelude'])) {
$blocks = true;
if ($item['prelude'] === '@layer material.components') {
expect($item['body'])->not->toContain('@layer');
} else {
$unlayered[$item['prelude']] = true;
}
continue;
}
expect($blocks)->toBeFalse("`{$item['statement']}` comes after a block")
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "`{$item['statement']}` is not a plain import");
}
// showcase.css has no imports of its own (its header says why); the day it needs one, this
// still holds because it is a plain @import assertion above, not a fixed empty list here.
expect($unlayered)->toBe([
// Deliberately unlayered, as an application's own classes are — see the file's header.
'.showcase-w-narrow' => true,
'.showcase-ink-tertiary' => true,
'.showcase-ink-secondary' => true,
]);
// Bundled with all.css on every showcase page, so it styles its own hooks and classes only,
// and each of those is still drawn by some showcase view.
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', ComponentStylesheet::withoutComments($css)), $preludes);
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
foreach ($preludes[1] as $prelude) {
foreach (array_map('trim', explode(',', $prelude)) as $selector) {
expect($selector)->toMatch('/^(?:\[data-md-showcase[\w-]*[\]=]|\.showcase-[\w-]+)/', "showcase.css selects `{$selector}`, outside the showcase's own hooks");
}
}
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', ComponentStylesheet::withoutComments($css), $hooks);
foreach (array_unique($hooks[0]) as $hook) {
expect(str_contains($views, ltrim($hook, '.')))->toBeTrue("showcase.css draws `{$hook}`, which no showcase view renders");
}
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments($css), $matches);
foreach ($matches[1] as $query) {
$feature = trim($query);
expect($feature)->not->toMatch('/\b(?:min|max)-(?:width|height)\b/', "{$feature} is not written as a range");
preg_match_all('/(\d*\.?\d+)([a-z%]*)/i', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $feature)
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$feature} is not at an M3 breakpoint");
}
}
});
it('keeps the showcase bundle, all.css with showcase.css, under a gzip size budget', function () {
// 40,167 bytes gzipped today (2026-09-17), measured with PHP's own gzencode() on
// resources/dist/showcase.css, the prebuilt bundle the showcase serves, comments stripped. A
// little organic growth should not retrigger this on every commit; a real jump means look at
// what grew since the budget was last raised.
$budget = 44_000;
$gzipped = strlen((string) gzencode(File::get(__DIR__.'/../../resources/dist/showcase.css'), 9));
expect($gzipped)->toBeLessThanOrEqual($budget, sprintf(
'The showcase bundle now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
number_format($gzipped),
number_format($budget),
));
});