From 66e219ae6387adeba21a73c16c1fc450c62be25e Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 13:43:33 +0200 Subject: [PATCH] Check the foundation's stylesheets Plan steps 33, 34 and 42. StylesheetsTest follows foundation.css through its imports and checks each file: a header, the exact layer statement, plain imports only before its first block, every rule in its own material layer but the two hiding rules, no Tailwind directive, media queries only at 600/840/1200/1600px. It also pins the spacing tokens to the reference table, the md-type classes to the type utilities' declarations, the whole text class set, the interaction classes against their utilities, one [x-cloak], material.css never reaching the reset, and the Workbench importing the foundation before Tailwind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- tests/Feature/StylesheetsTest.php | 427 ++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 tests/Feature/StylesheetsTest.php diff --git a/tests/Feature/StylesheetsTest.php b/tests/Feature/StylesheetsTest.php new file mode 100644 index 00000000..2d6df214 --- /dev/null +++ b/tests/Feature/StylesheetsTest.php @@ -0,0 +1,427 @@ +` 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 + */ +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 + */ +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 + */ +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 + */ +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 + */ +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.., below the rest of . + ->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 before Tailwind\'s', 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';"); + + 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'); +});