Files
livewire-material/tests/Feature/StylesheetsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 d7653aa6d0 Put Tailwind's preflight below the material layers in the Workbench
Plan step 36. A component rewritten without Tailwind keeps its padding,
margins and borders in material.components, and Tailwind's preflight
(* { padding: 0; margin: 0; border: 0 solid }) in its base layer, named
after material, undid every one of them. The Workbench now names theme and
base before importing the foundation, so the preflight — the same reset
foundation/reset.css carries — sits below the package, while Tailwind's
components and utilities stay above it and a caller's utility still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:59:30 +02:00

432 lines
18 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;
/**
* 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.
*
* Until the components leave Tailwind, the checks cover the foundation — foundation.css and every
* file it imports, text.css included — and material.css and tailwind.css carry the rest.
*/
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'));
}
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 () {
$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')
// 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', 'material.css'])))->toBe([])
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
});
it('opens every foundation stylesheet with a header, the layer statement and plain imports', function () {
foreach (foundationStylesheets() 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, where both the foundation and material.css reach it', 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();
$material = array_map(stylesheetName(...), stylesheetTree(stylesheetPath('material.css')));
expect($definitions)->toBe(['foundation/hidden.css'])
->and($material)->toContain('foundation/hidden.css', 'tailwind.css')
// Imported after Tailwind, the reset would sit in a layer above Tailwind's utilities.
->and(array_values(array_intersect($material, ['foundation.css', 'foundation/reset.css', 'foundation/base.css', 'foundation/interaction.css', 'text.css'])))->toBe([]);
});
it('writes no Tailwind directive in the foundation', function () {
foreach (foundationStylesheets() 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 in the foundation only at M3\'s breakpoints, in px', function () {
$queries = collect(foundationStylesheets())
->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]);
});
// The foundation's own queries (hover, reduced motion) hold no width; the check is for what
// lands here later.
expect($queries)->not->toBeEmpty();
foreach ($queries as $query) {
preg_match_all('/(\d*\.?\d+)([a-z%]*)/i', substr($query, strpos($query, ': ') + 2), $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $query)
->and(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);
}
});
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);
$utilities = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
$text = File::get(stylesheetPath('text.css'));
expect($utilities)->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']);
}
});
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);
}
});
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'));
$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 beside the old one, and M3's 48
// CSS pixels for the target where the utility reads 3rem.
['&:is([data-md-dragged], [data-dragged])', 'var(--md-sys-measurement-space600)'],
['&[data-dragged]', '3rem'],
$class,
)))->toBe($normalise(stylesheetBlock($utilities, "@utility {$name}")), $name);
}
expect(stylesheetBlock($interaction, '.md-state-layer'))->toContain('&:is([data-md-dragged], [data-dragged])::before')
->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);');
});
it('declares the material layers in the Workbench above Tailwind\'s preflight and below its utilities', function () {
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
$foundation = strpos($app, "@import '../../../resources/css/foundation.css';");
$tailwind = strpos($app, "@import 'tailwindcss'");
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");
// Tailwind's theme and preflight are named before the material layers, below them: the
// preflight is the foundation's own reset, and above `material` it would zero every
// component's padding. Its components and utilities come after, above the package.
expect(stylesheetItems($app)[0]['statement'] ?? null)->toBe('@layer theme, base;')
->and($foundation)->toBeInt()
->and($tailwind)->toBeGreaterThan($foundation)
->and($components)->toBeGreaterThan($tailwind)
// material.css would declare the tokens a second time.
->and($app)->not->toContain('material.css');
});