Files
livewire-material/tests/Feature/StylesheetsTest.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

727 lines
31 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;
/**
* 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.
*
* 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;';
function stylesheetPath(string $path = ''): string
{
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
}
/**
* A stylesheet without its comments; a quoted string keeps whatever it holds.
*/
function stylesheetWithoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The top-level statements (`@layer …;`, `@import …;`) and blocks of a stylesheet, in order, with
* whitespace collapsed in statements and preludes.
*
* @return list<array{statement: string}|array{prelude: string, body: string}>
*/
function stylesheetItems(string $css): array
{
$css = stylesheetWithoutComments($css);
$items = [];
$start = 0;
$depth = 0;
$quote = null;
$opening = 0;
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
$char = $css[$i];
if ($quote !== null) {
if ($char === '\\') {
$i++;
} elseif ($char === $quote) {
$quote = null;
}
continue;
}
if ($char === '"' || $char === "'") {
$quote = $char;
} elseif ($char === '{') {
if ($depth++ === 0) {
$opening = $i;
}
} elseif ($char === '}' && --$depth === 0) {
$items[] = [
'prelude' => (string) preg_replace('/\s+/', ' ', trim(substr($css, $start, $opening - $start))),
'body' => substr($css, $opening + 1, $i - $opening - 1),
];
$start = $i + 1;
} elseif ($char === ';' && $depth === 0) {
$items[] = ['statement' => preg_replace('/\s+/', ' ', trim(substr($css, $start, $i - $start))).';'];
$start = $i + 1;
}
}
expect(trim(substr($css, $start)))->toBe('', 'The stylesheet ends inside an unclosed rule.');
return $items;
}
/**
* The declarations of a flat block body, by property.
*
* @return array<string, string>
*/
function stylesheetDeclarations(string $body): array
{
return collect(explode(';', $body))
->map(fn (string $declaration): string => trim($declaration))
->filter()
->mapWithKeys(fn (string $declaration): array => [trim(strstr($declaration, ':', true)) => trim(substr(strstr($declaration, ':'), 1))])
->all();
}
/**
* 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 (stylesheetItems($css) as $item) {
if (($item['prelude'] ?? null) === $prelude) {
return $item['body'];
}
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
foreach (stylesheetItems($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(stylesheetItems(File::get($file)))
->map(fn (array $item): ?string => preg_match('/^@import\s+([\'"])(.+?)\1/', $item['statement'] ?? '', $match) === 1 ? $match[2] : null)
->filter()
->values()
->all();
}
/**
* 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.
*
* @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 (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>
*/
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.
->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')
->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);
$items = stylesheetItems(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; tokens.css only imports.
$layers = [
'foundation.css' => null,
'foundation/reset.css' => 'material.reset',
'foundation/hidden.css' => null,
'foundation/tokens.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(stylesheetWithoutComments(File::get($file)), '!important');
foreach (stylesheetItems(File::get($file)) as $item) {
if (! isset($item['prelude'])) {
continue;
}
if (! str_starts_with($item['prelude'], '@layer ')) {
$unlayered["{$name}: {$item['prelude']}"] = stylesheetDeclarations($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(stylesheetWithoutComments($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'));
});
it('writes no Tailwind directive anywhere all.css reaches', function () {
foreach (allStylesheets() as $file) {
expect(stylesheetWithoutComments(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));
}
});
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([^{]*)\{/', stylesheetWithoutComments(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/InputStylesheetsTest.php's
// assertBreakpointsInPx()): 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('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 = stylesheetDeclarations(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('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 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.
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 its own group's stylesheet test.
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(stylesheetWithoutComments($css))->not->toContain('!important', $name);
foreach (stylesheetItems($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(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
);
expect(Layout::SPACING)->toBe($tokens);
$spacing = File::get(stylesheetPath('layout/spacing.css'));
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
foreach ($tokens as $token) {
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
}
expect(collect(stylesheetItems(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(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
$rule = stylesheetItems($item['body'])[0];
expect(stylesheetDeclarations($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 () {
// 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]}" => stylesheetDeclarations($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-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(stylesheetItems(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(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
}
foreach ($rest as $class => $declarations) {
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
}
});
// 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 () {
$app = stylesheetWithoutComments(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).
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',
]);
});
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 = stylesheetItems($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(stylesheetWithoutComments($css))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
$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[^{]*\{/', '', stylesheetWithoutComments($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+)+/', stylesheetWithoutComments($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([^{]*)\{/', stylesheetWithoutComments($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");
}
}
});