` 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), and Tailwind * itself is gone from the package (plan step 39), 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, and — one file it does not reach — `showcase.css`, checked on * its own further down. 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. Every one of them is plain CSS (plan step 36), and Tailwind itself is * gone from the package (plan step 39) — `tailwindcss`, `tailwind.css` and its two token files no * longer exist, so there is nothing left for this tree to exclude. * * @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 else', 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') ->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]); }); it('paints the page in surface and on-surface, in the brand typeface, smoothed in grayscale', function () { // An application drops Tailwind's `antialiased` with nothing to replace it: the showcase and the // error pages, which set it themselves before, read it from here too. $base = (string) preg_replace('~/\*.*?\*/~s', '', (string) file_get_contents(stylesheetPath('foundation/base.css'))); expect($base)->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);\s*color: var\(--md-sys-color-on-surface\);\s*font-family: var\(--md-ref-typeface-brand\);\s*-webkit-font-smoothing: antialiased;\s*-moz-osx-font-smoothing: grayscale;\s*\}/') ->and((string) file_get_contents(stylesheetPath('showcase.css')).file_get_contents(stylesheetPath('components/error-page.css'))) ->not->toContain('font-smoothing'); }); it('leaves no stylesheet under resources/css/ outside all.css or showcase.css', function () { // The full tree now that Tailwind is gone (plan step 39): every .css file on disk, not just the // ones a bundler happens to reach — a stray or orphaned file would show up here even though // nothing imports it, which the checks above (walking @import from all.css) cannot catch. $onDisk = collect(File::allFiles(stylesheetPath())) ->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css') ->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath())) ->sort() ->values(); $reached = collect(allStylesheets())->map(stylesheetName(...))->push('showcase.css')->sort()->values(); expect($onDisk->all())->toBe($reached->all()); }); 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 no Tailwind utility class anywhere under resources/ or tests/', function () { // DesignGuard's own family table (check (i)) is the standing definition of a Tailwind // utility or variant — this test is that guard turned on the package's own source, so the // two never drift apart. tests/Feature/DesignGuardTest.php and tests/Fixtures/design-guard/ // carry Tailwind on purpose (they are the guard's own fixtures) and are left out. $root = __DIR__.'/../..'; $designGuardTest = (string) realpath($root.'/tests/Feature/DesignGuardTest.php'); $paths = collect(['resources/views', 'resources/js', 'src', 'tests/Browser', 'tests/Feature']) ->flatMap(fn (string $directory): Collection => collect(File::allFiles($root.'/'.$directory))) ->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'js', 'ts'], true)) ->reject(fn (SplFileInfo $file): bool => (string) $file->getRealPath() === $designGuardTest) ->map(fn (SplFileInfo $file): string => (string) $file->getRealPath()) ->values() ->all(); expect($paths)->not->toBeEmpty(); expect(DesignGuard::scan($paths)->violations())->toBe([]); }); 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'); // Range syntax (`width >= 840px`, `840px <= width < 1200px`), as the layout stylesheets' // own check required before it folded into this one: never `min-width`/`max-width`. expect($feature)->not->toMatch('/\b(?:min|max)-(?:width|height|aspect-ratio)\b/', "{$query} is not written as a range"); 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('gives every component view a stylesheet, or lists it on a short, documented exception', function () { // theme-script renders an inline