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'); +});