Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -1,10 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
||||
@@ -18,87 +16,14 @@ use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
* 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.
|
||||
* and so on) are ComponentStylesheetsTest's own concern.
|
||||
* The prebuilt bundles in resources/dist/ are 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();
|
||||
return (string) realpath(ComponentStylesheet::cssPath($path));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,13 +31,13 @@ function stylesheetDeclarations(string $body): array
|
||||
*/
|
||||
function stylesheetBlock(string $css, string $prelude): string
|
||||
{
|
||||
foreach (stylesheetItems($css) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||||
if (($item['prelude'] ?? null) === $prelude) {
|
||||
return $item['body'];
|
||||
}
|
||||
|
||||
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
|
||||
foreach (stylesheetItems($item['body']) as $inner) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body'])) as $inner) {
|
||||
if (($inner['prelude'] ?? null) === $prelude) {
|
||||
return $inner['body'];
|
||||
}
|
||||
@@ -130,8 +55,8 @@ function stylesheetBlock(string $css, string $prelude): 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)
|
||||
return collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))))
|
||||
->map(fn (array $item): ?string => ComponentStylesheet::importPath($item['statement'] ?? ''))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
@@ -205,8 +130,10 @@ it('brings every foundation file and text.css into the foundation, and nothing e
|
||||
|
||||
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'])
|
||||
// In the order of their layers, the rules outside every layer beside the reset — the seven
|
||||
// token files imported directly (plan step 33: foundation/tokens.css held only these
|
||||
// imports, and only foundation.css reached it, so it was folded in here).
|
||||
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.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([]);
|
||||
@@ -253,7 +180,7 @@ it('leaves no stylesheet under resources/css/ outside all.css or showcase.css',
|
||||
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));
|
||||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments(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");
|
||||
@@ -275,12 +202,11 @@ it('opens every stylesheet all.css reaches with a header, the layer statement an
|
||||
});
|
||||
|
||||
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.
|
||||
// Which layer each file writes into.
|
||||
$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',
|
||||
@@ -293,15 +219,15 @@ it('keeps every foundation rule inside its material layer, but the two that hide
|
||||
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');
|
||||
$important += substr_count(ComponentStylesheet::withoutComments(File::get($file)), '!important');
|
||||
|
||||
foreach (stylesheetItems(File::get($file)) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))) as $item) {
|
||||
if (! isset($item['prelude'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($item['prelude'], '@layer ')) {
|
||||
$unlayered["{$name}: {$item['prelude']}"] = stylesheetDeclarations($item['body']);
|
||||
$unlayered["{$name}: {$item['prelude']}"] = ComponentStylesheet::flatDeclarations($item['body']);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -321,7 +247,7 @@ it('keeps every foundation rule inside its material layer, but the two that hide
|
||||
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]'))
|
||||
->filter(fn (SplFileInfo $file): bool => str_contains(ComponentStylesheet::withoutComments($file->getContents()), '[x-cloak]'))
|
||||
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
|
||||
->values()
|
||||
->all();
|
||||
@@ -333,37 +259,21 @@ it('defines x-cloak once, reached from both the foundation and all.css', functio
|
||||
|
||||
it('writes no Tailwind directive anywhere all.css reaches', function () {
|
||||
foreach (allStylesheets() as $file) {
|
||||
expect(stylesheetWithoutComments(File::get($file)))
|
||||
expect(ComponentStylesheet::withoutComments(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([]);
|
||||
});
|
||||
// No Tailwind-shaped class anywhere the package ships or tests itself is
|
||||
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
|
||||
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
|
||||
// either.
|
||||
|
||||
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);
|
||||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments(File::get($file)), $matches);
|
||||
|
||||
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
|
||||
});
|
||||
@@ -376,9 +286,9 @@ it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, i
|
||||
$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.
|
||||
// Time pickers/Behaviour, and tests/Feature/Components/ComponentStylesheetsTest.php's
|
||||
// token-values test): 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');
|
||||
@@ -408,7 +318,7 @@ it('declares M3\'s spacing scale as the reference gives it', function () {
|
||||
);
|
||||
|
||||
$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'));
|
||||
$tokens = ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
|
||||
|
||||
expect($reference)->toHaveCount(13)
|
||||
->and($tokens)->toBe($reference);
|
||||
@@ -495,11 +405,11 @@ it('gives every component view a stylesheet, or lists it on a short, documented
|
||||
});
|
||||
|
||||
/**
|
||||
* Every component view the four group stylesheet tests (Action/Input/Containment/Navigation) used
|
||||
* to check one at a time, in one dataset instead: every `resources/views/components/*.blade.php`
|
||||
* backed by a `components/*.css` file of its own, `tabs` and `tab` folded into the one entry
|
||||
* `imports…()` below already special-cases (they share tabs.css, N-16), `theme-script` (no
|
||||
* stylesheet at all) and every layout component (the test above covers those) left out.
|
||||
* Every component view ComponentStylesheetsTest used to check one at a time, in one dataset
|
||||
* instead: every `resources/views/components/*.blade.php` backed by a `components/*.css` file of
|
||||
* its own, `tabs` and `tab` folded into the one entry `imports…()` below already special-cases
|
||||
* (they share tabs.css), `theme-script` (no stylesheet at all) and every layout component (the
|
||||
* test above covers those) left out.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -517,17 +427,34 @@ function componentStylesheetViews(): array
|
||||
|
||||
dataset('component stylesheet views', componentStylesheetViews());
|
||||
|
||||
/**
|
||||
* The `<x-livewire-material::…>` tags a view renders, including any inside a
|
||||
* `@include('livewire-material::partials.<name>', …)` it pulls in — markup a view moved out to
|
||||
* resources/views/partials/<name>.blade.php (menu's filter field, the timepicker's period picker)
|
||||
* still renders the same tags, just not inline where a plain regex over the view alone would see
|
||||
* them.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function renderedComponentTags(string $view): array
|
||||
{
|
||||
$content = File::get($view);
|
||||
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', $content, $tags);
|
||||
preg_match_all('/@include\(\'livewire-material::partials\.([\w.-]+)\'/', $content, $partials);
|
||||
|
||||
return collect($tags[1])
|
||||
->merge(collect($partials[1])->flatMap(fn (string $partial): array => renderedComponentTags(__DIR__."/../../resources/views/partials/{$partial}.blade.php")))
|
||||
->all();
|
||||
}
|
||||
|
||||
it('imports, from every component stylesheet, the stylesheet of each component its view renders', function (string $name) {
|
||||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php (N-16); every other
|
||||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php; every other
|
||||
// component stylesheet in this dataset has exactly one view of its own name.
|
||||
$views = $name === 'tabs' ? ['tabs', 'tab'] : [$name];
|
||||
|
||||
$rendered = collect($views)
|
||||
->flatMap(function (string $view): array {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../resources/views/components/{$view}.blade.php"), $tags);
|
||||
|
||||
return $tags[1];
|
||||
})
|
||||
->flatMap(fn (string $view): array => renderedComponentTags(__DIR__."/../../resources/views/components/{$view}.blade.php"))
|
||||
->unique()
|
||||
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
|
||||
// <x-pane>) has no import of its own to check here.
|
||||
@@ -607,7 +534,7 @@ it('imports, from every layout stylesheet, the stylesheet of each component its
|
||||
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.
|
||||
// button) — each already checked, in material.components, by ComponentStylesheetsTest.
|
||||
foreach (layoutStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
|
||||
@@ -621,9 +548,9 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
|
||||
default => 'material.layout',
|
||||
};
|
||||
|
||||
expect(stylesheetWithoutComments($css))->not->toContain('!important', $name);
|
||||
expect(ComponentStylesheet::withoutComments($css))->not->toContain('!important', $name);
|
||||
|
||||
foreach (stylesheetItems($css) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||||
if (! isset($item['prelude'])) {
|
||||
continue;
|
||||
}
|
||||
@@ -637,21 +564,21 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
|
||||
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'))),
|
||||
array_keys(ComponentStylesheet::flatDeclarations(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']);
|
||||
expect(ComponentStylesheet::flatDeclarations(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(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||||
->and(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||||
}
|
||||
|
||||
expect(collect(stylesheetItems(stylesheetBlock($spacing, '@layer material.layout')))->pluck('prelude')->all())
|
||||
expect(collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($spacing, '@layer material.layout'))))->pluck('prelude')->all())
|
||||
->toHaveCount(1 + 2 * count($tokens));
|
||||
});
|
||||
|
||||
@@ -668,10 +595,10 @@ it('hides an element below and from every breakpoint but compact, and nothing el
|
||||
$expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']";
|
||||
}
|
||||
|
||||
$actual = collect(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
|
||||
$rule = stylesheetItems($item['body'])[0];
|
||||
$actual = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments($visibility)))->mapWithKeys(function (array $item): array {
|
||||
$rule = ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body']))[0];
|
||||
|
||||
expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||
expect(ComponentStylesheet::flatDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||
|
||||
return [$item['prelude'] => $rule['prelude']];
|
||||
})->all();
|
||||
@@ -686,7 +613,7 @@ it('gives every md-type class exactly font, letter-spacing and font-variation-se
|
||||
// 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])]);
|
||||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
|
||||
|
||||
expect($classes)->toHaveCount(30);
|
||||
|
||||
@@ -730,18 +657,18 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
|
||||
],
|
||||
];
|
||||
|
||||
$classes = collect(stylesheetItems(stylesheetBlock($text, '@layer material.text')))
|
||||
$classes = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(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})"]);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
|
||||
}
|
||||
|
||||
foreach ($rest as $class => $declarations) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -754,7 +681,7 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
|
||||
// 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'));
|
||||
$app = ComponentStylesheet::withoutComments(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).
|
||||
@@ -775,11 +702,11 @@ it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left
|
||||
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);
|
||||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($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))
|
||||
->and(ComponentStylesheet::withoutComments($css))
|
||||
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
|
||||
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
|
||||
|
||||
@@ -814,7 +741,7 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
|
||||
// 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);
|
||||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', ComponentStylesheet::withoutComments($css)), $preludes);
|
||||
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
|
||||
|
||||
foreach ($preludes[1] as $prelude) {
|
||||
@@ -823,13 +750,13 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
}
|
||||
}
|
||||
|
||||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', stylesheetWithoutComments($css), $hooks);
|
||||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', ComponentStylesheet::withoutComments($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);
|
||||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments($css), $matches);
|
||||
|
||||
foreach ($matches[1] as $query) {
|
||||
$feature = trim($query);
|
||||
@@ -845,17 +772,17 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps all.css under a gzip size budget, measured through Stylesheets::bundle()', function () {
|
||||
// 106,838 bytes gzipped today (2026-09-15), rounded up by about 10% to a number CI can hold
|
||||
// to without Node: PHP's own gzencode() on the same bytes Stylesheets::bundle() serves the
|
||||
// showcase and the error page's fallback. A little organic growth should not retrigger this
|
||||
// on every commit; a real jump means look at what grew since the budget was last raised.
|
||||
$budget = 120_000;
|
||||
it('keeps the showcase bundle, all.css with showcase.css, under a gzip size budget', function () {
|
||||
// 40,167 bytes gzipped today (2026-09-17), measured with PHP's own gzencode() on
|
||||
// resources/dist/showcase.css, the prebuilt bundle the showcase serves, comments stripped. A
|
||||
// little organic growth should not retrigger this on every commit; a real jump means look at
|
||||
// what grew since the budget was last raised.
|
||||
$budget = 44_000;
|
||||
|
||||
$gzipped = strlen((string) gzencode(Stylesheets::bundle([stylesheetPath('all.css')]), 9));
|
||||
$gzipped = strlen((string) gzencode(File::get(__DIR__.'/../../resources/dist/showcase.css'), 9));
|
||||
|
||||
expect($gzipped)->toBeLessThanOrEqual($budget, sprintf(
|
||||
'all.css now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||||
'The showcase bundle now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||||
number_format($gzipped),
|
||||
number_format($budget),
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user