A rewritten component's padding, margin and border sit in material.components, which the Workbench declared below all of Tailwind's layers, so preflight's universal reset beat them. The entry's layer statement now puts material between base and components (plan step 36): the material reset already carries preflight, and every utility still outranks the package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
432 lines
18 KiB
PHP
432 lines
18 KiB
PHP
<?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 between Tailwind\'s preflight and its utilities', function () {
|
||
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||
|
||
// The first statement orders the layers: above preflight, whose `* { padding: 0 }` would
|
||
// otherwise beat a rewritten component's padding, and below the utilities.
|
||
expect(trim($app))->toStartWith('@layer theme, base, material, components, utilities;');
|
||
|
||
$foundation = strpos($app, "@import '../../../resources/css/foundation.css';");
|
||
$tailwind = strpos($app, "@import 'tailwindcss'");
|
||
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");
|
||
|
||
expect($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');
|
||
});
|