` 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), 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 — everything but * `tailwind.css` and `tokens/theme.css`/`tokens/utilities.css`, which stay Tailwind until plan * step 39 and are simply never reached from `all.css`. 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 */ 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')); } /** * Everything all.css reaches: the foundation, every layout stylesheet and every component * stylesheet, each once. What a check runs over now that every one of them is plain CSS (plan * step 36) — `tailwind.css` and its two token files are never in this tree, since all.css does * not import them. * * @return list */ 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 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'])))->toBe([]) ->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]); }); 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.., 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, 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'); 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 */ 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('/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 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 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 in place of the old one, and M3's // 48 CSS pixels for the target where the utility reads 3rem. ['&[data-md-dragged]', 'var(--md-sys-measurement-space600)'], ['&[data-dragged]', '3rem'], $class, )))->toBe($normalise(stylesheetBlock($utilities, "@utility {$name}")), $name); } expect(stylesheetBlock($interaction, '.md-state-layer'))->toContain('&[data-md-dragged]::before') ->not->toContain('[data-dragged]') ->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;'); $all = strpos($app, "@import '../../../resources/css/all.css';"); $tailwind = strpos($app, "@import 'tailwindcss'"); $components = strpos($app, "@import '../../../resources/css/tailwind.css';"); expect($all)->toBeInt() ->and($tailwind)->toBeGreaterThan($all) ->and($components)->toBeGreaterThan($tailwind) // material.css declared the tokens a second time; components.css and layout.css are the // interim lists all.css replaced. ->and($app)->not->toContain('material.css') ->not->toContain("resources/css/components.css'") ->not->toContain("resources/css/layout.css'"); });