` 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); } }); /** * The layout components' stylesheets: layout.css and everything it imports. * * @return list */ function layoutStylesheets(): array { return stylesheetTree(stylesheetPath('layout.css')); } it('imports a stylesheet for every layout component from layout.css, each beside its view', function () { $imports = stylesheetImports(stylesheetPath('layout.css')); $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. $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 ($components as $name) { expect(File::exists(__DIR__."/../../resources/views/components/{$name}.blade.php"))->toBeTrue("layout/{$name}.css has no component") ->and($imports)->toContain("./layout/{$name}.css"); } }); it('opens every layout stylesheet with a header, the layer statement and plain imports', function () { foreach (layoutStylesheets() 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") ->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "{$name}: `{$item['statement']}` is not a plain import"); } } }); it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () { foreach (layoutStylesheets() as $file) { $name = stylesheetName($file); $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('writes no Tailwind in the layout stylesheets, and media queries only at M3\'s breakpoints, in px', function () { $queries = 0; foreach (layoutStylesheets() as $file) { $name = stylesheetName($file); $css = stylesheetWithoutComments(File::get($file)); expect($css) ->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', $name) ->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', $name); preg_match_all('/@media\b([^{]*)\{/', $css, $matches); foreach ($matches[1] as $query) { $queries++; expect(trim($query))->toMatch('/^\(width (?:<|>=) (?:600|840|1200|1600)px\)$/', "{$name}: `@media {$query}` is not an M3 breakpoint in px"); } } expect($queries)->toBeGreaterThan(0); }); 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 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'); });