Plan step 39 (parts 1-3, folded into one commit: PHP tests read the deleted files directly, so they cannot land apart from it). Tailwind leaves the whole stack: - Delete resources/css/tailwind.css, tokens/theme.css and tokens/utilities.css. Neither token file declared an --md-sys-* custom property of its own (both only referenced tokens declared elsewhere), so nothing loses a value; the md-* interaction and text classes already mirror utilities.css's declarations exactly (foundation/interaction.css, text.css). - npm uninstall tailwindcss @tailwindcss/vite; vite.config.js drops the plugin and its import; composer.json drops the tailwindcss keyword (no lock change — keywords are outside Composer's content hash). - The Workbench now builds one CSS entry, workbench/resources/css/app.css (all.css, showcase.css and the scheme; package.css is folded in and removed) instead of two, used by ErrorPage::assets() and every browser-test probe page's raw @vite() call; the showcase's own pages still take their CSS from the bundle route. - TokensTest and StylesheetsTest: the two facts theme.css and utilities.css carried (every scheme role becomes a colour, resolved on the element; md-type-* matches the type-* utilities) are asserted directly against the scheme and text.css now that there is no second copy to cross-check; StylesheetsTest gained a full-tree scan (every .css file under resources/css/ is reached from all.css or showcase.css, no exclusions left for Tailwind); the Workbench-entry test and every "moved out of tailwind.css" assertion updated for the single entry and its removal. - DesignGuard.php's comments and the development skill's setup section no longer name the deleted files or a second Tailwind entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
727 lines
31 KiB
PHP
727 lines
31 KiB
PHP
<?php
|
||
|
||
use Illuminate\Support\Facades\File;
|
||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||
|
||
/**
|
||
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
||
* one opens with the same layer statement, so the order holds whichever file a bundler reaches
|
||
* first; then plain imports (an `@import` cannot sit inside a layer block, and `layer()` on it
|
||
* would nest the layer twice); then its own `@layer material.<x>` 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<array{statement: string}|array{prelude: string, body: string}>
|
||
*/
|
||
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<string, string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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('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.<x>.<y>, below the rest of <x>.
|
||
->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');
|
||
|
||
// 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<string>
|
||
*/
|
||
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('/<x-livewire-material::([a-z-]+)/', File::get($view), $tags);
|
||
$imports = stylesheetImports($file->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 <x-{$tag}> 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 exactly font, letter-spacing and font-variation-settings', function () {
|
||
// Tailwind's tokens/utilities.css used to declare the same 30 styles as `type-*` utility
|
||
// blocks, and this test proved text.css's `.md-type-*` classes matched them; deleted with
|
||
// Tailwind (plan step 39), text.css is the only copy left, so the shape it gives each class is
|
||
// asserted directly rather than cross-checked against a second copy.
|
||
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
|
||
|
||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
|
||
|
||
expect($classes)->toHaveCount(30);
|
||
|
||
foreach ($classes as $class => $declarations) {
|
||
expect(array_keys($declarations))->toBe(['font', 'letter-spacing', 'font-variation-settings'], $class);
|
||
}
|
||
});
|
||
|
||
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);
|
||
}
|
||
});
|
||
|
||
// A test used to prove foundation/interaction.css's `.md-*` classes matched tokens/utilities.css's
|
||
// utility blocks byte for byte apart from two deliberate differences, because the components
|
||
// still written in Tailwind read the utilities while every other component read the classes.
|
||
// Deleted with Tailwind (plan step 39): every component reads the classes now, there is no second
|
||
// copy left to drift from, and interaction.css's own StylesheetsTest coverage above (the layer it
|
||
// writes into, no Tailwind directive) already holds it to the same shape every other foundation
|
||
// file keeps to.
|
||
|
||
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
|
||
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||
|
||
// Opens by ordering the layers, so the order holds whichever the page links first — an
|
||
// application with Tailwind of its own still opens its own entry the same way (its header).
|
||
expect(trim($app))->toStartWith(MATERIAL_LAYER_STATEMENT)
|
||
->toContain("@import '../../../resources/css/all.css';")
|
||
->toContain("@import '../../../resources/css/showcase.css';")
|
||
->toContain("@import './material-scheme.css';")
|
||
->not->toMatch('/@(?:tailwind|source|theme|utility|variant|custom-variant|apply|config|plugin|reference)\b/')
|
||
->and(File::get(__DIR__.'/../../vite.config.js'))
|
||
->toContain("input: ['workbench/resources/css/app.css', 'workbench/resources/js/app.js']")
|
||
->not->toContain('tailwindcss')
|
||
->and(config('livewire-material.showcase.vite'))->toBe([
|
||
'workbench/resources/css/app.css',
|
||
'workbench/resources/js/app.js',
|
||
]);
|
||
});
|
||
|
||
it('shapes showcase.css like a package stylesheet, with its documented unlayered exceptions', function () {
|
||
$path = __DIR__.'/../../resources/css/showcase.css';
|
||
$css = File::get($path);
|
||
$items = stylesheetItems($css);
|
||
|
||
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
|
||
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement')
|
||
->and(stylesheetWithoutComments($css))
|
||
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
|
||
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
|
||
|
||
$blocks = false;
|
||
$unlayered = [];
|
||
|
||
foreach (array_slice($items, 1) as $item) {
|
||
if (isset($item['prelude'])) {
|
||
$blocks = true;
|
||
|
||
if ($item['prelude'] === '@layer material.components') {
|
||
expect($item['body'])->not->toContain('@layer');
|
||
} else {
|
||
$unlayered[$item['prelude']] = true;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
expect($blocks)->toBeFalse("`{$item['statement']}` comes after a block")
|
||
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "`{$item['statement']}` is not a plain import");
|
||
}
|
||
|
||
// showcase.css has no imports of its own (its header says why); the day it needs one, this
|
||
// still holds because it is a plain @import assertion above, not a fixed empty list here.
|
||
expect($unlayered)->toBe([
|
||
// Deliberately unlayered, as an application's own classes are — see the file's header.
|
||
'.showcase-w-narrow' => true,
|
||
'.showcase-ink-tertiary' => true,
|
||
'.showcase-ink-secondary' => true,
|
||
]);
|
||
|
||
// Bundled with all.css on every showcase page, so it styles its own hooks and classes only,
|
||
// and each of those is still drawn by some showcase view.
|
||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', stylesheetWithoutComments($css)), $preludes);
|
||
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
|
||
|
||
foreach ($preludes[1] as $prelude) {
|
||
foreach (array_map('trim', explode(',', $prelude)) as $selector) {
|
||
expect($selector)->toMatch('/^(?:\[data-md-showcase[\w-]*[\]=]|\.showcase-[\w-]+)/', "showcase.css selects `{$selector}`, outside the showcase's own hooks");
|
||
}
|
||
}
|
||
|
||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', stylesheetWithoutComments($css), $hooks);
|
||
|
||
foreach (array_unique($hooks[0]) as $hook) {
|
||
expect(str_contains($views, ltrim($hook, '.')))->toBeTrue("showcase.css draws `{$hook}`, which no showcase view renders");
|
||
}
|
||
|
||
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments($css), $matches);
|
||
|
||
foreach ($matches[1] as $query) {
|
||
$feature = trim($query);
|
||
|
||
expect($feature)->not->toMatch('/\b(?:min|max)-(?:width|height)\b/', "{$feature} 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', $feature)
|
||
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$feature} is not at an M3 breakpoint");
|
||
}
|
||
}
|
||
});
|