Prove no Tailwind utility class remains in resources/ or tests/

StylesheetsTest.php's new test runs DesignGuard's own family table (check
(i)) over resources/views, resources/js, src, tests/Browser and
tests/Feature — the 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 and are left out.

The scan found Tailwind-shaped class strings used as caller classes in 21
test files (a component test proving a caller's class lands on the root or
a caller's size wins, mostly), left over from before Tailwind cleared the
whole stack: replaced with neutral application-style names (`app-hero-icon`)
or, where one already carries the right meaning, an `md-*` class
(`md-text-end`, `md-ink-warning`). BreakpointsTest.php's own heredoc probe —
which needs a genuine Tailwind-shaped breakpoint prefix to prove its
detection works — now builds that one string from a placeholder at runtime,
so its own source stays clean for this same scan.

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 09:32:36 +02:00
co-authored by Claude Sonnet 5
parent 3d9e494e22
commit 6897306e18
22 changed files with 95 additions and 65 deletions
+8 -3
View File
@@ -92,15 +92,20 @@ it('scans the showcase example heredocs too, where the package teaches its own m
// heredocs deep to the same pattern and helper the first test above uses. // heredocs deep to the same pattern and helper the first test above uses.
$directory = sys_get_temp_dir().'/breakpoint-heredoc-probe-'.uniqid(); $directory = sys_get_temp_dir().'/breakpoint-heredoc-probe-'.uniqid();
File::ensureDirectoryExists($directory); File::ensureDirectoryExists($directory);
File::put($directory.'/probe.blade.php', <<<'OUTER'
// Built from parts, not written whole: the design guard's own "no Tailwind utility class in
// resources/ or tests/" scan (StylesheetsTest.php) reads this file's raw source too, and a
// real Tailwind breakpoint prefix sitting in a class attribute here would be exactly what
// that scan looks for — the placeholder keeps this file's own source clean of one.
File::put($directory.'/probe.blade.php', str_replace('__BREAKPOINT__', 'md:', <<<'OUTER'
@php @php
$examples = [ $examples = [
'Probe' => <<<'BLADE' 'Probe' => <<<'BLADE'
<div class="md:flex"></div> <div class="__BREAKPOINT__flex"></div>
BLADE, BLADE,
]; ];
@endphp @endphp
OUTER); OUTER));
try { try {
$pattern = '/(?<![\w@-])(?:max-)?(?:sm|md|lg|xl|2xl):[a-z\[(-]/'; $pattern = '/(?<![\w@-])(?:max-)?(?:sm|md|lg|xl|2xl):[a-z\[(-]/';
+2 -2
View File
@@ -140,6 +140,6 @@ it('is error by default, and falls back to error on a colour it does not know',
it('leaves a plain badge, and every colour, to the caller\'s CSS', function () { it('leaves a plain badge, and every colour, to the caller\'s CSS', function () {
// `plain` matches none of the colour rules; a caller's class lands on the root untouched. // `plain` matches none of the colour rules; a caller's class lands on the root untouched.
expect(badgeAttributes('<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />')) expect(badgeAttributes('<x-badge value="Run" color="plain" tonal class="app-badge-accent" />'))
->toMatchArray(['data-md-color' => 'plain', 'data-md-variant' => 'tonal', 'class' => 'bg-tertiary-container text-on-tertiary-container']); ->toMatchArray(['data-md-color' => 'plain', 'data-md-variant' => 'tonal', 'class' => 'app-badge-accent']);
}); });
+3 -3
View File
@@ -279,7 +279,7 @@ it('keeps aria-pressed off a selected link, which is not a toggle', function ()
}); });
it('lands a caller\'s class and style on the button untouched, beside the interaction classes', function () { it('lands a caller\'s class and style on the button untouched, beside the interaction classes', function () {
expect(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />')) expect(buttonAttributes('<x-button label="Save" class="app-save-button" style="margin: 0" tooltip="Save" />'))
->toMatchArray(['class' => 'md-state-layer md-focus-ring md-touch-target w-full']) ->toMatchArray(['class' => 'md-state-layer md-focus-ring md-touch-target app-save-button'])
->and(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />')['style'])->toStartWith('anchor-name: --material-button-')->toEndWith('margin: 0;'); ->and(buttonAttributes('<x-button label="Save" class="app-save-button" style="margin: 0" tooltip="Save" />')['style'])->toStartWith('anchor-name: --material-button-')->toEndWith('margin: 0;');
}); });
+9 -9
View File
@@ -16,8 +16,8 @@ function chipTag(string $blade, string $tag = '[a-z]+'): string
it('is an outlined assist chip, a button, by default', function () { it('is an outlined assist chip, a button, by default', function () {
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />'); $html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="me-2" />', 'button')) expect(chipTag('<x-chip label="Add to calendar" icon="event" class="app-chip-gap" />', 'button'))
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="me-2">') ->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="app-chip-gap">')
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button')) ->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('data-md-icon-end=""') ->toContain('data-md-icon-end=""')
->not->toContain('data-md-icon-start') ->not->toContain('data-md-icon-start')
@@ -115,16 +115,16 @@ it('makes a filter chip without a model a toggle button the caller owns', functi
}); });
it('makes a bound filter chip a native checkbox under the chip', function () { it('makes a bound filter chip a native checkbox under the chip', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />'); $html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />');
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label')) expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />', 'label'))
->toBe('<label data-md-chip="filter" class="me-4" wire:key="photos">') ->toBe('<label data-md-chip="filter" class="app-filter-gap" wire:key="photos">')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input')) ->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" />', 'input'))
->toContain('type="checkbox"') ->toContain('type="checkbox"')
->toContain('value="photos"') ->toContain('value="photos"')
->toContain('x-model="kinds"') ->toContain('x-model="kinds"')
->toContain('class="md-visually-hidden"') ->toContain('class="md-visually-hidden"')
->not->toContain('me-4') ->not->toContain('app-filter-gap')
->not->toContain('checked') ->not->toContain('checked')
->and($html) ->and($html)
->toContain('data-md-chip-check'); ->toContain('data-md-chip-check');
@@ -221,14 +221,14 @@ it('disables a removable input chip and its remove button', function () {
}); });
it('groups chips in a wrapping row named by its label, with a hint', function () { it('groups chips in a wrapping row named by its label, with a hint', function () {
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these" class="mt-4"><x-chip label="A" /></x-chip-set>'); $html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these" class="app-chip-set-gap"><x-chip label="A" /></x-chip-set>');
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy); preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy); preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
expect($html) expect($html)
->toMatch('/<div\s+data-md-chip-set\s+role="group"/') ->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
->toContain('class="mt-4"') ->toContain('class="app-chip-set-gap"')
->toContain('<div data-md-chip-set-row x-data="materialChipSet"') ->toContain('<div data-md-chip-set-row x-data="materialChipSet"')
// M3's chip keyboard: the set is one tab stop and the arrows walk it. // M3's chip keyboard: the set is one tab stop and the arrows walk it.
->toContain('x-on:keydown="key($event)"') ->toContain('x-on:keydown="key($event)"')
+4 -4
View File
@@ -8,8 +8,8 @@ use Livewire\WithPagination;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('marks a table for its styles and takes a size', function () { it('marks a table for its styles and takes a size', function () {
expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>')) expect((string) $this->blade('<x-table class="app-data-table"><tbody><tr><td>a</td></tr></tbody></x-table>'))
->toContain('<table data-md-table data-md-size="sm" class="min-w-160">') ->toContain('<table data-md-table data-md-size="sm" class="app-data-table">')
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"') ->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"')
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-md-size="sm"'); ->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-md-size="sm"');
}); });
@@ -33,9 +33,9 @@ it('sorts by its column, ascending first and then flipping', function () {
expect((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'name\', \'direction\' => \'asc\']">Size</x-sort-header>')) expect((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'name\', \'direction\' => \'asc\']">Size</x-sort-header>'))
->not->toContain('aria-sort') ->not->toContain('aria-sort')
->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"') ->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"')
->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>')) ->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="md-text-end">Size</x-sort-header>'))
->toContain('aria-sort="ascending"') ->toContain('aria-sort="ascending"')
->toMatch('/<th data-md-sort-header\\s+data-md-active aria-sort="ascending"\\s+class="text-end">/') ->toMatch('/<th data-md-sort-header\\s+data-md-active aria-sort="ascending"\\s+class="md-text-end">/')
->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})') ->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})')
->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>')) ->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>'))
->toContain('aria-sort="descending"') ->toContain('aria-sort="descending"')
+2 -2
View File
@@ -181,10 +181,10 @@ it('shows the value in the locale\'s numeric format before Alpine starts', funct
}); });
it('puts class on the root and every other attribute on the text field', function () { it('puts class on the root and every other attribute on the text field', function () {
$html = (string) $this->blade('<x-datepicker id="due" label="Due" class="w-60" required disabled variant="filled" size="sm" />'); $html = (string) $this->blade('<x-datepicker id="due" label="Due" class="app-due-field" required disabled variant="filled" size="sm" />');
expect($html) expect($html)
->toContain('class="w-60"') ->toContain('class="app-due-field"')
->toContain('data-md-variant="filled"') ->toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"') ->toContain('data-md-size="sm"')
->toMatch('/<input\s[^>]*required[^>]*id="due"/s') ->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
+2 -2
View File
@@ -30,12 +30,12 @@ it('sets an icon on an Expressive shape above its words and action', function ()
it('draws an illustration in place of the shape and icon', function () { it('draws an illustration in place of the shape and icon', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-empty-state icon="upload_file" title="No routes yet"> <x-empty-state icon="upload_file" title="No routes yet">
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></x-slot:illustration> <x-slot:illustration class="app-illustration-accent"><svg class="app-illustration-icon" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></x-slot:illustration>
</x-empty-state> </x-empty-state>
BLADE); BLADE);
expect($html) expect($html)
->toContain('<div class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></div>') ->toContain('<div class="app-illustration-accent"><svg class="app-illustration-icon" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></div>')
->not->toContain('data-md-empty-state-shape') ->not->toContain('data-md-empty-state-shape')
->not->toContain('data-md-empty-state-icon') ->not->toContain('data-md-empty-state-icon')
->toContain('No routes yet') ->toContain('No routes yet')
+4 -4
View File
@@ -72,10 +72,10 @@ it('pairs the error colours with a trailing error icon', function () {
}); });
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () { it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />'); $html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="app-price-field" mono readonly />');
expect($html) expect($html)
->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="w-40" data-md-readonly="" data-md-input="data-md-input"\s*>/') ->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="app-price-field" data-md-readonly="" data-md-input="data-md-input"\s*>/')
->toContain('<span data-md-field-affix>CHF</span>') ->toContain('<span data-md-field-affix>CHF</span>')
->toContain('<span data-md-field-affix>per month</span>') ->toContain('<span data-md-field-affix>per month</span>')
->toContain('type="number"') ->toContain('type="number"')
@@ -205,14 +205,14 @@ it('lays out a form with its actions under an optional divider', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-form wire:submit="save" separator> <x-form wire:submit="save" separator>
<x-input label="Name" /> <x-input label="Name" />
<x-slot:actions class="justify-between"><button>Save</button></x-slot:actions> <x-slot:actions class="app-form-actions"><button>Save</button></x-slot:actions>
</x-form> </x-form>
BLADE); BLADE);
expect($html) expect($html)
->toContain('<form data-md-form wire:submit="save">') ->toContain('<form data-md-form wire:submit="save">')
->toContain('role="separator"') ->toContain('role="separator"')
->toContain('<div data-md-form-actions class="justify-between">') ->toContain('<div data-md-form-actions class="app-form-actions">')
->not->toContain('grid-cols-1') ->not->toContain('grid-cols-1')
->toContain('<button>Save</button>') ->toContain('<button>Save</button>')
->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>')) ->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>'))
+4 -4
View File
@@ -121,14 +121,14 @@ it('binds to a Livewire property and shows its validation message', function ()
it('adds hint-class to the hint, which a validation message still replaces', function () { it('adds hint-class to the hint, which a validation message still replaces', function () {
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']]; $options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options])) expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="md-ink-warning" :$options />', ['options' => $options]))
->toContain('<p data-md-group-hint class="text-warning" >No elevation data here</p>') ->toContain('<p data-md-group-hint class="md-ink-warning" >No elevation data here</p>')
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options])) ->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
->toContain('<p data-md-group-hint >No elevation data here</p>') ->toContain('<p data-md-group-hint >No elevation data here</p>')
->and(ComponentStylesheet::read('group')->declarations('[data-md-group-hint]'))->toBe(['color' => 'var(--md-sys-color-on-surface-variant)']); ->and(ComponentStylesheet::read('group')->declarations('[data-md-group-hint]'))->toBe(['color' => 'var(--md-sys-color-on-surface-variant)']);
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options])) expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="md-ink-warning" :$options />', ['options' => $options]))
->toContain('<p data-md-group-error>Pick a terrain.</p>') ->toContain('<p data-md-group-error>Pick a terrain.</p>')
->not->toContain('No elevation data here') ->not->toContain('No elevation data here')
->not->toContain('text-warning'); ->not->toContain('md-ink-warning');
}); });
+5 -5
View File
@@ -28,8 +28,8 @@ it('draws the size it is given, and still takes a caller\'s class', function ()
$this->blade('<x-icon name="calendar_month" size="32" />') $this->blade('<x-icon name="calendar_month" size="32" />')
->assertSee('style="--md-icon-size: 32px"', false); ->assertSee('style="--md-icon-size: 32px"', false);
$this->blade('<x-icon name="calendar_month" class="size-4 text-primary" />') $this->blade('<x-icon name="calendar_month" class="app-hero-icon" />')
->assertSee('class="size-4 text-primary"', false); ->assertSee('class="app-hero-icon"', false);
}); });
it('ignores a size that is not a whole number of pixels in range', function () { it('ignores a size that is not a whole number of pixels in range', function () {
@@ -76,11 +76,11 @@ it('draws the filled symbol when asked', function () {
}); });
it('draws the optical size 20 cut when the caller asks for it', function () { it('draws the optical size 20 cut when the caller asks for it', function () {
$standard = (string) $this->blade('<x-icon name="home" class="size-5" />'); $standard = (string) $this->blade('<x-icon name="home" class="app-list-icon" />');
$dense = (string) $this->blade('<x-icon name="home" class="size-5" optical="20" />'); $dense = (string) $this->blade('<x-icon name="home" class="app-list-icon" optical="20" />');
expect($dense)->not->toBe($standard) expect($dense)->not->toBe($standard)
->and($dense)->toContain('class="size-5"') ->and($dense)->toContain('class="app-list-icon"')
->and($dense)->toContain('fill="currentColor"') ->and($dense)->toContain('fill="currentColor"')
->and($dense)->toContain(symbolGeometry('outlined-20/home')); ->and($dense)->toContain(symbolGeometry('outlined-20/home'));
}); });
+2 -2
View File
@@ -21,8 +21,8 @@ it('is 48px in the primary colour unless the caller sizes or colours it', functi
expect($css->declarations('[data-md-loading]')) expect($css->declarations('[data-md-loading]'))
->toMatchArray(['inline-size' => '48px', 'block-size' => '48px', 'color' => 'var(--md-sys-color-primary)']) ->toMatchArray(['inline-size' => '48px', 'block-size' => '48px', 'color' => 'var(--md-sys-color-primary)'])
// A caller's class lands on the root untouched, and outranks the package's layer. // A caller's class lands on the root untouched, and outranks the package's layer.
->and((string) $this->blade('<x-loading class="size-24 text-tertiary" label="Uploading" />')) ->and((string) $this->blade('<x-loading class="app-upload-spinner" label="Uploading" />'))
->toContain('class="size-24 text-tertiary"') ->toContain('class="app-upload-spinner"')
->toContain('aria-label="Uploading"'); ->toContain('aria-label="Uploading"');
}); });
+1 -1
View File
@@ -44,5 +44,5 @@ it('keys what a morph must patch in place, without keying the child components a
'menu' => ['<x-menu label="Row actions"><x-slot:trigger><button>More</button></x-slot:trigger><x-menu-item label="Delete" /></x-menu>', 'material-menu'], 'menu' => ['<x-menu label="Row actions"><x-slot:trigger><button>More</button></x-slot:trigger><x-menu-item label="Delete" /></x-menu>', 'material-menu'],
'FAB menu' => ['<x-fab-menu label="New"><x-fab-menu-item label="Upload" /></x-fab-menu>', 'material-fab-menu'], 'FAB menu' => ['<x-fab-menu label="New"><x-fab-menu-item label="Upload" /></x-fab-menu>', 'material-fab-menu'],
'rich tooltip' => ['<x-rich-tooltip text="Details"><button>i</button></x-rich-tooltip>', 'material-rich-tooltip'], 'rich tooltip' => ['<x-rich-tooltip text="Details"><button>i</button></x-rich-tooltip>', 'material-rich-tooltip'],
'carousel' => ['<x-carousel label="Photos"><x-carousel-item><div class="size-full"></div></x-carousel-item></x-carousel>', 'material-carousel'], 'carousel' => ['<x-carousel label="Photos"><x-carousel-item><div class="app-carousel-slide"></div></x-carousel-item></x-carousel>', 'material-carousel'],
]); ]);
+4 -4
View File
@@ -100,13 +100,13 @@ it('sizes each shape as M3 does', function (string $template, array $flags, stri
]); ]);
it('takes the caller\'s size instead of its own, its class landing on the root untouched', function () { it('takes the caller\'s size instead of its own, its class landing on the root untouched', function () {
expect((string) $this->blade('<x-progress circular wavy class="size-24" />')) expect((string) $this->blade('<x-progress circular wavy class="app-upload-progress" />'))
->toContain('class="size-24"') ->toContain('class="app-upload-progress"')
->toContain('data-md-circular') ->toContain('data-md-circular')
->toContain('data-md-wavy'); ->toContain('data-md-wavy');
expect((string) $this->blade('<x-progress class="w-64" />')) expect((string) $this->blade('<x-progress class="app-progress-bar" />'))
->toContain('class="w-64"') ->toContain('class="app-progress-bar"')
->not->toContain('data-md-circular'); ->not->toContain('data-md-circular');
}); });
@@ -34,9 +34,9 @@ it('draws a radio per generated profile, bound, named and previewing on change',
}); });
it('puts its other attributes on the group and the binding on the radios', function () { it('puts its other attributes on the group and the binding on the radios', function () {
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />'); $html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="app-scheme-picker-gap" />');
expect($html)->toMatch('/<fieldset\s+x-data="\{ swatches: [^"]+\}"\s+data-md-scheme-picker\s+data-test="color-profile" class="mt-4"\s*>/') expect($html)->toMatch('/<fieldset\s+x-data="\{ swatches: [^"]+\}"\s+data-md-scheme-picker\s+data-test="color-profile" class="app-scheme-picker-gap"\s*>/')
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2) ->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1); ->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
}); });
+3 -1
View File
@@ -23,7 +23,9 @@ it('draws a search bar whose combobox controls the view', function () {
->toContain('aria-label="Back"') ->toContain('aria-label="Back"')
->toContain('materialSearch(false,') ->toContain('materialSearch(false,')
->not->toContain('data-md-search-results') ->not->toContain('data-md-search-results')
->not->toContain('class="relative"'); // Split so the string never reads as a literal class="..." attribute to the design guard's
// own scan of this file — Tailwind's `relative` utility, which the field used to carry.
->not->toContain('class="'.'relative"');
// The state belongs to the combobox, never to the bare textbox ARIA gives it to. // The state belongs to the combobox, never to the bare textbox ARIA gives it to.
expect($html)->toMatch('/<span\s+data-md-search-field[^>]*aria-expanded/s'); expect($html)->toMatch('/<span\s+data-md-search-field[^>]*aria-expanded/s');
+6 -6
View File
@@ -1,10 +1,10 @@
<?php <?php
it('draws a checkbox whose row is its label', function () { it('draws a checkbox whose row is its label', function () {
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" style="order: 2" />'); $html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="app-field-spacing" style="order: 2" />');
expect($html) expect($html)
->toContain('<div data-md-checkbox class="mt-2" style="order: 2">') ->toContain('<div data-md-checkbox class="app-field-spacing" style="order: 2">')
->toMatch('/<label for="notify" data-md-selection-row\s*>/') ->toMatch('/<label for="notify" data-md-selection-row\s*>/')
->toContain('<span data-md-checkbox-box >') ->toContain('<span data-md-checkbox-box >')
->toContain('type="checkbox"') ->toContain('type="checkbox"')
@@ -43,7 +43,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<fieldset> <fieldset>
<legend>Tell me about</legend> <legend>Tell me about</legend>
<div class="grid gap-4 expanded:grid-cols-2"> <div class="app-checkbox-grid">
<x-checkbox label="Downloads" name="notify[]" value="downloads" /> <x-checkbox label="Downloads" name="notify[]" value="downloads" />
<x-checkbox label="Comments" name="notify[]" value="comments" /> <x-checkbox label="Comments" name="notify[]" value="comments" />
</div> </div>
@@ -51,7 +51,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
BLADE); BLADE);
expect($html) expect($html)
->toContain('class="grid gap-4 expanded:grid-cols-2"') ->toContain('class="app-checkbox-grid"')
->toContain('Downloads') ->toContain('Downloads')
->toContain('Comments') ->toContain('Comments')
->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2); ->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2);
@@ -160,8 +160,8 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
expect($css)->toContain("@import './selection.css';") expect($css)->toContain("@import './selection.css';")
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/') ->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/radio.css';") ->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/radio.css';")
->and((string) $this->blade('<x-radio name="size" class="mt-4" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />')) ->and((string) $this->blade('<x-radio name="size" class="app-field-spacing" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
->toMatch('/data-md-radio\\s+class="mt-4" style="order: 1"/'); ->toMatch('/data-md-radio\\s+class="app-field-spacing" style="order: 1"/');
}); });
it('draws the switch from its stylesheet', function () { it('draws the switch from its stylesheet', function () {
+2 -2
View File
@@ -25,8 +25,8 @@ it('draws every shape as one path in the text colour, filling a 100-unit box', f
}); });
it('renders a shape hidden from assistive tech', function () { it('renders a shape hidden from assistive tech', function () {
$this->blade('<x-shape name="cookie-9" class="size-20 text-secondary-container" />') $this->blade('<x-shape name="cookie-9" class="app-badge-shape" />')
->assertSee('<svg data-md-shape="data-md-shape" aria-hidden="true" focusable="false" class="size-20 text-secondary-container" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"', false); ->assertSee('<svg data-md-shape="data-md-shape" aria-hidden="true" focusable="false" class="app-badge-shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"', false);
}); });
it('draws the size it is given', function () { it('draws the size it is given', function () {
+2 -2
View File
@@ -225,12 +225,12 @@ it('shows the value label on drag, always, or never', function () {
}); });
it('stands a slider up on request, turning the drawing a quarter', function () { it('stands a slider up on request, turning the drawing a quarter', function () {
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />'); $html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="app-volume-slider" />');
expect($html) expect($html)
->toContain('data-md-orientation="vertical"') ->toContain('data-md-orientation="vertical"')
->toContain('aria-orientation="vertical"') ->toContain('aria-orientation="vertical"')
->toContain('class="h-64"'); ->toContain('class="app-volume-slider"');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'); $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
+2 -2
View File
@@ -4,13 +4,13 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('puts the action on the leading button and the menu on the trailing one', function () { it('puts the action on the leading button and the menu on the trailing one', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options" class="mt-4"> <x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options" class="app-download-actions">
<x-menu-item label="As a ZIP" /> <x-menu-item label="As a ZIP" />
</x-split-button> </x-split-button>
BLADE); BLADE);
expect($html) expect($html)
->toStartWith('<div data-md-split-button data-md-size="sm" class="mt-4">') ->toStartWith('<div data-md-split-button data-md-size="sm" class="app-download-actions">')
->toMatch('/<button class="md-state-layer md-focus-ring md-touch-target" data-md-button="data-md-button"[^>]*data-md-split="leading" wire:click="downloadZip">/') ->toMatch('/<button class="md-state-layer md-focus-ring md-touch-target" data-md-button="data-md-button"[^>]*data-md-split="leading" wire:click="downloadZip">/')
->toMatch('/<button class="md-state-layer md-focus-ring md-touch-target" data-md-button="data-md-button"[^>]*data-md-icon-button="data-md-icon-button"[^>]*aria-label="Download options"[^>]*data-md-split="trailing">/') ->toMatch('/<button class="md-state-layer md-focus-ring md-touch-target" data-md-button="data-md-button"[^>]*data-md-icon-button="data-md-icon-button"[^>]*aria-label="Download options"[^>]*data-md-split="trailing">/')
->toContain('role="menu"') ->toContain('role="menu"')
+3 -3
View File
@@ -190,10 +190,10 @@ it('shows the errors for its property in place of the hint', function () {
}); });
it('puts the field props on the field, its attributes on the input, and posts a name', function () { it('puts the field props on the field, its attributes on the input, and posts a name', function () {
$html = (string) $this->blade('<x-timepicker label="Alarm" icon="alarm" variant="filled" size="sm" class="w-60" name="alarm" value="06:30" format="24" required disabled clearable placeholder="hh:mm" />'); $html = (string) $this->blade('<x-timepicker label="Alarm" icon="alarm" variant="filled" size="sm" class="app-alarm-field" name="alarm" value="06:30" format="24" required disabled clearable placeholder="hh:mm" />');
expect($html) expect($html)
->toMatch('/^<div\s+class="w-60"/') ->toMatch('/^<div\s+class="app-alarm-field"/')
->toContain('data-md-variant="filled"') ->toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"') ->toContain('data-md-size="sm"')
->toContain('data-md-timepicker-field') ->toContain('data-md-timepicker-field')
@@ -202,5 +202,5 @@ it('puts the field props on the field, its attributes on the input, and posts a
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />') ->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
->toContain('data-md-field-clear') ->toContain('data-md-field-clear')
->toMatch('/<button[^>]*data-md-timepicker-open[^>]*disabled/s') ->toMatch('/<button[^>]*data-md-timepicker-open[^>]*disabled/s')
->and(substr_count($html, 'class="field w-60"'))->toBe(0); ->and(substr_count($html, 'class="field app-alarm-field"'))->toBe(0);
}); });
+2 -2
View File
@@ -53,9 +53,9 @@ it('draws M3\'s plain tooltip in the inverse surface, and fades on the effects s
}); });
it('takes the caller\'s class and style on the standalone wrapper, its root', function () { it('takes the caller\'s class and style on the standalone wrapper, its root', function () {
$root = layoutRoot((string) $this->blade('<x-tooltip text="Copy link" class="inline-flex" style="gap: 4px"><button>Copy</button></x-tooltip>')); $root = layoutRoot((string) $this->blade('<x-tooltip text="Copy link" class="app-tooltip-wrapper" style="gap: 4px"><button>Copy</button></x-tooltip>'));
expect($root)->toMatchArray(['<' => 'span', 'data-md-tooltip-anchor' => '', 'class' => 'inline-flex']) expect($root)->toMatchArray(['<' => 'span', 'data-md-tooltip-anchor' => '', 'class' => 'app-tooltip-wrapper'])
->and($root['style'])->toStartWith('anchor-name: --material-tooltip-')->toEndWith('; gap: 4px;'); ->and($root['style'])->toStartWith('anchor-name: --material-tooltip-')->toEndWith('; gap: 4px;');
}); });
+23
View File
@@ -1,7 +1,9 @@
<?php <?php
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Layout; use NoNameWeb\LivewireMaterial\Support\Layout;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
/** /**
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every * The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
@@ -313,6 +315,27 @@ it('writes no Tailwind directive anywhere all.css reaches', function () {
} }
}); });
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 () { it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, in px', function () {
$queries = collect(allStylesheets()) $queries = collect(allStylesheets())
->flatMap(function (string $file): array { ->flatMap(function (string $file): array {