Files
livewire-material/tests/Feature/DesignGuardTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a 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>
2026-09-17 19:29:21 +02:00

737 lines
66 KiB
PHP

<?php
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
const GUARD_FIXTURES = __DIR__.'/../Fixtures/design-guard';
/**
* @param list<string> $violations
* @return list<string>
*/
function fixtureRelative(array $violations): array
{
return array_map(fn (string $violation): string => str_replace(realpath(GUARD_FIXTURES).'/', '', $violation), $violations);
}
/**
* Every check that only scans a fixture, optionally names the CSS entry `missingStylesheets()`
* should check against, and compares the result to an exact violations list — most of this file,
* kept as one `it()` over a `[fixture, entry, expected]` dataset instead of one near-identical test
* per check. A test that does more (chains `forbidColours()`/`forbid()`/`unusedStylesheets()`,
* compares two scans against each other, mutates config or the view finder, or asserts more than
* once) stays its own test, further down.
*/
dataset('design guard scans', [
'finds what compiles to nothing' => [
[realpath(GUARD_FIXTURES.'/app'), realpath(GUARD_FIXTURES.'/views')],
null,
[
'app/Status.php:14 Tailwind palette colour `text-red-500` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS',
'views/page.blade.php:2 Tailwind spacing utility `p-4` compiles to nothing — use `padding="space200"` (16px) on `<x-surface>`, or `var(--md-sys-measurement-space200)` in your own CSS',
'views/page.blade.php:3 unknown Material Symbol `o-home`',
'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`',
'views/page.blade.php:5 Tailwind palette colour `text-red-600` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS',
'views/page.blade.php:6 Tailwind colour utility `text-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
'views/page.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
"views/page.blade.php:8 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
],
],
'names what a component tag takes in place of each directive' => [
realpath(GUARD_FIXTURES.'/directives'),
null,
[
'directives/tags.blade.php:1 Blade directive `@js` inside a component tag, where it does not compile — use `{{ \\Illuminate\\Support\\Js::from(…) }}`',
'directives/tags.blade.php:2 Blade directive `@json` inside a component tag, where it does not compile — use `{{ json_encode(…) }}`',
'directives/tags.blade.php:3 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
'directives/tags.blade.php:4 Blade directive `@style` inside a component tag, where it does not compile — use `:style="\\Illuminate\\Support\\Arr::toCssStyles([…])"`',
"directives/tags.blade.php:5 Blade directive `@entangle` inside a component tag, where it does not compile — use `\$wire.entangle('…')` in the Alpine expression",
'directives/tags.blade.php:6 Blade directive `@disabled` inside a component tag, where it does not compile — use `:disabled="…"`',
'directives/tags.blade.php:7 Blade directive `@if` inside a component tag, where it does not compile — use a `:prop` binding or `{{ }}`, or move it to a plain element inside the slot',
],
],
'names the M3 breakpoint for a Tailwind prefix, with its 2.0.0 replacement' => [
realpath(GUARD_FIXTURES.'/breakpoints'),
null,
[
"breakpoints/layout.blade.php:1 Tailwind breakpoint `2xl:` compiles to nothing — M3's extra-large (1600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 1600px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `lg:` compiles to nothing — M3's expanded (840px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 840px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `md:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 600px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `sm:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 600px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `xl:` compiles to nothing — M3's large (1200px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 1200px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-2xl:` compiles to nothing — M3's extra-large (1600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 1600px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-lg:` compiles to nothing — M3's expanded (840px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 840px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-md:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 600px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-sm:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 600px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-xl:` compiles to nothing — M3's large (1200px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 1200px)` in your own CSS",
],
],
'names the 2.0.0 replacement for a pseudo-class variant, but leaves a breakpoint prefix to its own finding' => [
realpath(GUARD_FIXTURES.'/tailwind/variant.blade.php'),
null,
[
"tailwind/variant.blade.php:1 Tailwind variant class `dark:opacity-50` compiles to nothing — the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS",
"tailwind/variant.blade.php:1 Tailwind variant class `hover:underline` compiles to nothing — M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS",
"tailwind/variant.blade.php:2 Tailwind breakpoint `md:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 600px)` in your own CSS",
"tailwind/variant.blade.php:2 Tailwind variant class `focus:md:gap-2` compiles to nothing — M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS",
'tailwind/variant.blade.php:2 Tailwind variant class `group-hover:flex` compiles to nothing — `:has()` or a descendant selector in your own CSS',
],
],
'leaves a breakpoint name that is not a variant alone' => [
__DIR__.'/../../resources/js/slider.js',
null,
[],
],
'names the M3 corner for a Tailwind radius' => [
realpath(GUARD_FIXTURES.'/scale/corners.blade.php'),
null,
[
'scale/corners.blade.php:1 value outside the M3 scale `rounded-lg` — use `var(--md-sys-shape-corner-lg)` in your own CSS, or `<x-surface corner="lg">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-md` — use `var(--md-sys-shape-corner-md)` in your own CSS, or `<x-surface corner="md">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-none` — use `var(--md-sys-shape-corner-none)` in your own CSS, or `<x-surface corner="none">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-sm` — use `var(--md-sys-shape-corner-sm)` in your own CSS, or `<x-surface corner="sm">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-xs` — use `var(--md-sys-shape-corner-xs)` in your own CSS, or `<x-surface corner="xs">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-2xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-3xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-4xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-full` — use `var(--md-sys-shape-corner-full)` in your own CSS, or `<x-surface corner="full">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-xl` — use `var(--md-sys-shape-corner-xl)` in your own CSS, or `<x-surface corner="xl">`',
'scale/corners.blade.php:3 value outside the M3 scale `rounded-se-2xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:3 value outside the M3 scale `rounded-t-lg` — use `var(--md-sys-shape-corner-lg)` in your own CSS, or `<x-surface corner="lg">`',
],
],
'names the M3 elevation level for a Tailwind shadow' => [
realpath(GUARD_FIXTURES.'/scale/elevation.blade.php'),
null,
[
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-2xs` — use `var(--md-sys-elevation-1)` in your own CSS',
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-md` — use `var(--md-sys-elevation-2)` in your own CSS',
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-sm` — use `var(--md-sys-elevation-1)` in your own CSS',
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-xs` — use `var(--md-sys-elevation-1)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-2xl` — use `var(--md-sys-elevation-5)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-lg` — use `var(--md-sys-elevation-3)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-xl` — use `var(--md-sys-elevation-4)` in your own CSS',
],
],
'sends a size, a weight, a leading and a tracking to a type style' => [
realpath(GUARD_FIXTURES.'/scale/type.blade.php'),
null,
[
'scale/type.blade.php:1 value outside the M3 scale `text-2xl` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-9xl` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-base` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-lg` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-sm` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-xl` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-xs` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:2 value outside the M3 scale `font-extralight` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-light` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-medium` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-normal` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-thin` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-black` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-bold` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-extrabold` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-semibold` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:4 value outside the M3 scale `leading-6` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-loose` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-none` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-normal` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-relaxed` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-snug` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-tight` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-normal` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-tight` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-tighter` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-wide` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-wider` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-widest` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
],
],
'sends an easing and a duration to the motion tokens' => [
realpath(GUARD_FIXTURES.'/scale/motion.blade.php'),
null,
[
'scale/motion.blade.php:1 value outside the M3 scale `ease-in-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:1 value outside the M3 scale `ease-in` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:1 value outside the M3 scale `ease-linear` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:1 value outside the M3 scale `ease-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:2 value outside the M3 scale `duration-1000` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
'scale/motion.blade.php:2 value outside the M3 scale `duration-300` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
'scale/motion.blade.php:2 value outside the M3 scale `duration-75` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
],
],
// scale/motion.js reads a real CSS keyword out of matchMedia() into a variable named `easing`
// (`'linear'`/`'ease-out'`), the shape the plan calls out as a false positive: nothing here is
// a class list, so the class-context match above never sees it, and the family is off
// outsideTheScale()'s line scan since the fix (see DesignGuard::outsideTheScale()'s header).
'leaves an easing or a duration word alone in a JavaScript string outside a class list' => [
realpath(GUARD_FIXTURES.'/scale/motion.js'),
null,
[],
],
'finds a colour written as a value' => [
realpath(GUARD_FIXTURES.'/colour'),
null,
[
'colour/badge.blade.php:1 arbitrary colour `bg-[#1d7afc]`, use an M3 role',
'colour/badge.blade.php:1 arbitrary colour `border-[hsl(210_80%_50%)]`, use an M3 role',
'colour/badge.blade.php:1 arbitrary colour `text-[rgb(0_0_0)]`, use an M3 role',
'colour/badge.blade.php:2 arbitrary colour `fill-[oklch(0.7_0.1_250)]`, use an M3 role',
'colour/badge.blade.php:2 arbitrary colour `ring-[color-mix(in_oklab,var(--x)_50%,transparent)]`, use an M3 role',
'colour/badge.blade.php:3 Tailwind colour utility `bg-white` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
'colour/badge.blade.php:3 Tailwind colour utility `border-white` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
'colour/badge.blade.php:3 Tailwind colour utility `text-black` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
"colour/badge.blade.php:4 Tailwind colour utility `bg-on-surface/12` compiles to nothing — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-on-surface) <n>%, transparent)` in your own CSS",
"colour/badge.blade.php:4 Tailwind colour utility `border-outline/38` compiles to nothing — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-outline) <n>%, transparent)` in your own CSS",
"colour/badge.blade.php:4 Tailwind colour utility `text-on-surface/60` compiles to nothing — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
],
],
'sends the state-layer opacities and every other role to their 2.0.0 replacement' => [
realpath(GUARD_FIXTURES.'/tailwind/inks.blade.php'),
null,
[
'tailwind/inks.blade.php:1 Tailwind colour utility `bg-background` compiles to nothing — use `<x-surface level="surface">`',
'tailwind/inks.blade.php:1 Tailwind colour utility `text-current` compiles to nothing — write `currentColor` in your own CSS',
'tailwind/inks.blade.php:1 Tailwind colour utility `text-on-primary-container` compiles to nothing — use `var(--md-sys-color-on-primary-container)` in your own CSS',
],
],
'names the md-ink-*, divider/surface or token replacement for a dead M3 role utility' => [
realpath(GUARD_FIXTURES.'/tailwind/roles.blade.php'),
null,
[
'tailwind/roles.blade.php:1 Tailwind colour utility `border-outline-variant` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility',
'tailwind/roles.blade.php:1 Tailwind colour utility `text-on-surface-variant` compiles to nothing — use `md-ink-variant` (text.css)',
'tailwind/roles.blade.php:2 Tailwind colour utility `bg-primary` compiles to nothing — use `var(--md-sys-color-primary)` in your own CSS',
'tailwind/roles.blade.php:2 Tailwind colour utility `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`',
],
],
'sends flex, grid and gap utilities to the layout component and prop that replaces each' => [
realpath(GUARD_FIXTURES.'/tailwind/layout.blade.php'),
null,
[
'tailwind/layout.blade.php:1 Tailwind layout utility `flex-col` compiles to nothing — use `<x-stack>` (`gap`, `align`)',
'tailwind/layout.blade.php:1 Tailwind layout utility `flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
'tailwind/layout.blade.php:1 Tailwind layout utility `grid` compiles to nothing — use `<x-grid>` (`:columns` per breakpoint, `gap`, `min-item`), or `<x-feed>` for a grid of cards',
'tailwind/layout.blade.php:1 Tailwind layout utility `inline-flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
"tailwind/layout.blade.php:2 Tailwind flex/grid item utility `col-span-2` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
"tailwind/layout.blade.php:2 Tailwind layout utility `grid-cols-3` compiles to nothing — use `<x-grid :columns=\"3\">`, or a per-breakpoint map (`:columns=\"['compact' => 1, 'medium' => 3]\"`)",
'tailwind/layout.blade.php:2 Tailwind layout utility `items-center` compiles to nothing — use `align="center"` on `<x-row>` or `<x-stack>`',
'tailwind/layout.blade.php:2 Tailwind layout utility `justify-between` compiles to nothing — use `justify="between"` on `<x-row>`',
'tailwind/layout.blade.php:2 Tailwind spacing utility `gap-4` compiles to nothing — use `gap="space200"` (16px) on `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
],
],
'sends padding, margin and space-between utilities to the M3 spacing step they are (gap-4 is space200)' => [
realpath(GUARD_FIXTURES.'/tailwind/spacing.blade.php'),
null,
[
"tailwind/spacing.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS",
'tailwind/spacing.blade.php:1 Tailwind spacing utility `p-4` compiles to nothing — use `padding="space200"` (16px) on `<x-surface>`, or `var(--md-sys-measurement-space200)` in your own CSS',
'tailwind/spacing.blade.php:1 Tailwind spacing utility `px-2` compiles to nothing — use `padding="space100"` (8px) on `<x-surface>`, or `var(--md-sys-measurement-space100)` in your own CSS',
'tailwind/spacing.blade.php:1 Tailwind spacing utility `space-y-4` compiles to nothing — use `<x-stack>` with `gap="space200"` (16px)',
"tailwind/spacing.blade.php:2 Tailwind spacing utility `m-4` compiles to nothing — space between siblings is a layout component's `gap=\"space200\"` (16px); any other margin is `var(--md-sys-measurement-space200)` in your own CSS",
'tailwind/spacing.blade.php:2 Tailwind spacing utility `space-x-2` compiles to nothing — use `<x-row>` with `gap="space100"` (8px)',
],
],
'sends width and height utilities to a literal length, since M3 keeps no size scale' => [
realpath(GUARD_FIXTURES.'/tailwind/sizing.blade.php'),
null,
[
"tailwind/sizing.blade.php:1 Tailwind sizing utility `h-screen` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `max-w-md` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `min-w-0` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `w-full` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:2 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
],
],
'sends hidden to hide-below/hide-from and every other display utility to a plain CSS rule' => [
realpath(GUARD_FIXTURES.'/tailwind/display.blade.php'),
null,
[
'tailwind/display.blade.php:1 Tailwind display utility `block` compiles to nothing — write the `display` rule in your own CSS',
"tailwind/display.blade.php:1 Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`",
'tailwind/display.blade.php:2 Tailwind display utility `inline-block` compiles to nothing — write the `display` rule in your own CSS',
'tailwind/display.blade.php:2 Tailwind display utility `invisible` compiles to nothing — write the `display` rule in your own CSS',
],
],
'sends text-layout utilities to their md-* class' => [
realpath(GUARD_FIXTURES.'/tailwind/text.blade.php'),
null,
[
"tailwind/text.blade.php:1 Tailwind's `text-center` compiles to nothing — use `md-text-center` (text.css)",
"tailwind/text.blade.php:1 Tailwind's `truncate` compiles to nothing — use `md-truncate` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `line-clamp-2` compiles to nothing — use `md-line-clamp-2` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `sr-only` compiles to nothing — use `md-visually-hidden` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `tabular-nums` compiles to nothing — use `md-tabular` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `whitespace-nowrap` compiles to nothing — use `md-nowrap` (text.css)",
],
],
'exempts a class the application\'s own CSS declares, even when it is Tailwind-shaped' => [
realpath(GUARD_FIXTURES.'/exempt'),
null,
[
"exempt/view.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS",
],
],
'reports an arbitrary [] value once, whatever utility it modifies, and strips a trailing !' => [
realpath(GUARD_FIXTURES.'/tailwind/arbitrary.blade.php'),
null,
[
'tailwind/arbitrary.blade.php:1 Tailwind arbitrary value `grid-cols-[1fr_2fr]` compiles to nothing — write the literal value in your own CSS, or use an M3 token',
'tailwind/arbitrary.blade.php:1 Tailwind arbitrary value `top-[10px]` compiles to nothing — write the literal value in your own CSS, or use an M3 token',
'tailwind/arbitrary.blade.php:2 Tailwind layout utility `flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
'tailwind/arbitrary.blade.php:4 Tailwind arbitrary property `[--material-bottom-bar:5rem]` compiles to nothing — write `--material-bottom-bar` in your own CSS',
"tailwind/arbitrary.blade.php:4 Tailwind arbitrary property `[font-variation-settings:'ROND'_100]` compiles to nothing — write `font-variation-settings` in your own CSS",
],
],
'leaves every role alone until an application forbids one' => [
realpath(GUARD_FIXTURES.'/forbidden-colours'),
null,
[
'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
],
],
'names the property a Tailwind custom-property utility stands for' => [
realpath(GUARD_FIXTURES.'/tailwind/custom-property.blade.php'),
null,
[
'tailwind/custom-property.blade.php:1 Tailwind custom-property utility `duration-(--md-sys-motion-spatial-fast-duration)` compiles to nothing — write `var(--md-sys-motion-spatial-fast-duration)` in your own CSS',
],
],
'reports position, border, effect, interactivity and text utilities with the rule to write instead' => [
realpath(GUARD_FIXTURES.'/tailwind/utilities.blade.php'),
null,
[
"tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `col-start-2` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
"tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `justify-self-end` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
"tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `shrink-0` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
'tailwind/utilities.blade.php:1 Tailwind inset utility `-top-2` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step',
'tailwind/utilities.blade.php:1 Tailwind inset utility `inset-0` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step',
'tailwind/utilities.blade.php:1 Tailwind overflow utility `overflow-hidden` compiles to nothing — write `overflow` in your own CSS',
"tailwind/utilities.blade.php:1 Tailwind position utility `relative` compiles to nothing — write `position` in your own CSS (a FAB goes in `<x-scaffold>`'s `fab` slot)",
'tailwind/utilities.blade.php:1 Tailwind z-index utility `z-10` compiles to nothing — write `z-index` in your own CSS',
"tailwind/utilities.blade.php:1 Tailwind's `container` compiles to nothing — use `<x-pane width>`, which sets M3's margins and a measure",
'tailwind/utilities.blade.php:2 Tailwind border utility `border-t-2` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`',
'tailwind/utilities.blade.php:2 Tailwind border utility `border` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`',
'tailwind/utilities.blade.php:2 Tailwind effect utility `-rotate-45` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:2 Tailwind interactivity utility `cursor-pointer` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:2 Tailwind interactivity utility `select-none` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:2 Tailwind motion utility `transition` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`',
"tailwind/utilities.blade.php:2 Tailwind opacity utility `opacity-50` compiles to nothing — write `opacity` in your own CSS (M3's disabled content is 38 %)",
"tailwind/utilities.blade.php:2 Tailwind outline utility `outline-none` compiles to nothing — M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS",
"tailwind/utilities.blade.php:2 Tailwind outline utility `ring-2` compiles to nothing — M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS",
'tailwind/utilities.blade.php:2 Tailwind radius utility `rounded` compiles to nothing — use `var(--md-sys-shape-corner-xs)` in your own CSS, or `<x-surface corner="xs">`',
'tailwind/utilities.blade.php:2 Tailwind shadow utility `shadow` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind background utility `bg-clip-text` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind media utility `aspect-video` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind media utility `object-cover` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind text utility `list-disc` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind text utility `uppercase` compiles to nothing — write the rule in your own CSS',
"tailwind/utilities.blade.php:3 Tailwind's `antialiased` compiles to nothing — the foundation already smooths text in grayscale (base.css); drop it",
"tailwind/utilities.blade.php:3 Tailwind's `font-mono` compiles to nothing — put the value in `<code>`, `<kbd>` or `<samp>`, or use `md-mono` (text.css)",
"tailwind/utilities.blade.php:3 Tailwind's `font-sans` compiles to nothing — the foundation already sets the brand typeface; any other `font-family` is your own CSS",
"tailwind/utilities.blade.php:3 Tailwind's `text-end` compiles to nothing — use `md-text-end` (text.css)",
"tailwind/utilities.blade.php:3 Tailwind's `underline` compiles to nothing — `md-link` draws a link (interaction.css); any other decoration is your own CSS",
],
],
'reads class lists in Alpine, Livewire and PHP bindings, but not a string compared in a condition' => [
realpath(GUARD_FIXTURES.'/tailwind/bindings.blade.php'),
null,
[
"tailwind/bindings.blade.php:1 Tailwind opacity utility `opacity-50` compiles to nothing — write `opacity` in your own CSS (M3's disabled content is 38 %)",
"tailwind/bindings.blade.php:1 Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`",
'tailwind/bindings.blade.php:2 Tailwind effect utility `scale-95` compiles to nothing — write the rule in your own CSS',
'tailwind/bindings.blade.php:2 Tailwind motion utility `transition` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`',
'tailwind/bindings.blade.php:2 value outside the M3 scale `ease-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'tailwind/bindings.blade.php:3 Tailwind spacing utility `gap-2` compiles to nothing — use `gap="space100"` (8px) on `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
"tailwind/bindings.blade.php:4 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS",
'tailwind/bindings.blade.php:6 Tailwind layout utility `flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
],
],
'names the missing package stylesheet and the exact @import line to add' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/split-button.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'),
[
"stylesheets/views/split-button.blade.php:1 `<x-split-button>` needs `components/split-button.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/split-button.css';`",
],
],
'counts what an imported package stylesheet already brings in, through its own imports' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/split-button.css'),
[],
],
'needs nothing more once the entry imports all.css' => [
realpath(GUARD_FIXTURES.'/stylesheets/views'),
realpath(GUARD_FIXTURES.'/stylesheets/all.css'),
[],
],
'says so when the CSS entry does not exist' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/plain.blade.php'),
'/nowhere/app.css',
['/nowhere/app.css:1 the CSS entry `missingStylesheets()` names does not exist'],
],
'requires foundation.css whatever the views hold' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/plain.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/missing-foundation.css'),
[
"stylesheets/missing-foundation.css:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import '../../../../resources/css/foundation.css';`",
],
],
'recognises a package tag written fully qualified' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/namespaced.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'),
[
"stylesheets/views/namespaced.blade.php:1 `<x-button>` needs `components/button.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/button.css';`",
],
],
'needs pagination.css when a view calls ->links()' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/pagination.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'),
[
"stylesheets/views/pagination.blade.php:1 `->links()` needs `components/pagination.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/pagination.css';`",
],
],
"finds every literal design value and off-scale media query in the application's own CSS" => [
realpath(GUARD_FIXTURES.'/app-css/app.css'),
null,
[
'app-css/app.css:3 literal colour `#ff0000` — use `var(--md-sys-color-*)`',
'app-css/app.css:4 literal colour `rgb(0, 0, 0)` — use `var(--md-sys-color-*)`',
'app-css/app.css:5 literal radius `border-radius: 12px` — use `var(--md-sys-shape-corner-*)`',
'app-css/app.css:6 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`',
'app-css/app.css:6 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`',
'app-css/app.css:7 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
'app-css/app.css:8 literal font weight `font-weight: 600` — use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`',
'app-css/app.css:9 literal line height `line-height: 1.4` — set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class',
'app-css/app.css:10 literal letter spacing `letter-spacing: 0.02em` — use `var(--md-sys-typescale-*-tracking)`',
'app-css/app.css:11 literal duration in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
'app-css/app.css:11 literal easing in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
'app-css/app.css:24 literal colour `white` — use `var(--md-sys-color-*)`',
'app-css/app.css:25 literal colour `#fff` — use `var(--md-sys-color-*)`',
"app-css/app.css:34 media query width `700px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`",
'app-css/app.css:43 literal radius `border-top-left-radius: calc(8px + 4px)` — use `var(--md-sys-shape-corner-*)`',
'app-css/app.css:46 literal font `font: 600 14px/20px sans-serif` — use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class',
'app-css/app.css:47 literal font size `font-size: clamp(1rem, 2vw, 2rem)` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
'app-css/app.css:48 literal line height `line-height: 1.5` — set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class',
"app-css/app.css:51 media query width `839px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`",
"app-css/app.css:53 media query width `40rem` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`",
],
],
// SealShare's drop zone pairs a spatial spring for scale with an effects spring for opacity in
// one transition-duration list; blanking both var()s used to leave a bare comma the guard called
// literal.
'accepts a list of values when every item is a token, and quotes a mixed one as written' => [
realpath(GUARD_FIXTURES.'/css-lists/lists.css'),
null,
[
'css-lists/lists.css:8 literal duration `transition-duration: var(--md-sys-motion-spatial-slow-duration), 200ms` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
'css-lists/lists.css:9 literal easing `transition-timing-function: var(--md-sys-motion-spatial-slow), ease-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
],
],
'leaves a box-shadow ring in a colour role alone, but still reports a blurred or literal-coloured one' => [
realpath(GUARD_FIXTURES.'/shadows/rings.css'),
null,
[
'shadows/rings.css:16 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`',
'shadows/rings.css:16 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`',
'shadows/rings.css:20 literal colour `#000` — use `var(--md-sys-color-*)`',
'shadows/rings.css:20 literal shadow `box-shadow: inset 0 0 4px 1px #000` — use `var(--md-sys-elevation-*)`',
],
],
'never reads a Tailwind-shaped word out of running text' => [
realpath(GUARD_FIXTURES.'/running-text.blade.php'),
null,
[],
],
]);
it('scans a fixture and reports exactly the expected violations', function (string|array $fixture, ?string $entry, array $expected) {
$guard = DesignGuard::scan($fixture);
expect(fixtureRelative(($entry === null ? $guard : $guard->missingStylesheets($entry))->violations()))->toBe($expected);
})->with('design guard scans');
it('reports a colour utility on the application\'s own theme colour, and a Tailwind utility of the same prefix as its own family', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/theme-colours.blade.php'))->violations());
$theme = fn (string $token, string $name, string $use = '`var(--…)`'): string => "Tailwind colour utility `{$token}` compiles to nothing — `{$name}` is neither an M3 role nor a Tailwind colour, so it named a colour of the application's own theme: use {$use} in your own CSS";
$tint = '`color-mix(in srgb, var(--…) <n>%, transparent)`';
$line = 'a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`';
$outline = "M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS";
expect($violations)->toBe([
'tailwind/theme-colours.blade.php:1 '.$theme('bg-off-plan', 'off-plan'),
'tailwind/theme-colours.blade.php:1 '.$theme('from-brand', 'brand'),
'tailwind/theme-colours.blade.php:1 '.$theme('text-brand-ink', 'brand-ink'),
'tailwind/theme-colours.blade.php:1 '.$theme('text-sport-run', 'sport-run'),
'tailwind/theme-colours.blade.php:1 '.$theme('to-brand-end', 'brand-end'),
'tailwind/theme-colours.blade.php:2 '.$theme('bg-route-reference/8', 'route-reference', $tint),
'tailwind/theme-colours.blade.php:2 '.$theme('border-l-zone-4', 'zone-4'),
'tailwind/theme-colours.blade.php:2 '.$theme('border-route-reference', 'route-reference'),
'tailwind/theme-colours.blade.php:2 '.$theme('ring-offset-brand/[0.4]', 'brand', $tint),
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-balance` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-clip` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-ellipsis` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-pretty` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-wrap` compiles to nothing — write the rule in your own CSS',
"tailwind/theme-colours.blade.php:3 Tailwind's `text-nowrap` compiles to nothing — use `md-nowrap` (text.css)",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-dashed` compiles to nothing — {$line}",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-dotted` compiles to nothing — {$line}",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-none` compiles to nothing — {$line}",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-solid` compiles to nothing — {$line}",
'tailwind/theme-colours.blade.php:4 Tailwind table utility `border-collapse` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:4 Tailwind table utility `border-separate` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:5 Tailwind gradient utility `from-0%` compiles to nothing — write the gradient in your own CSS',
'tailwind/theme-colours.blade.php:5 Tailwind gradient utility `via-none` compiles to nothing — write the gradient in your own CSS',
"tailwind/theme-colours.blade.php:5 Tailwind outline utility `outline-dashed` compiles to nothing — {$outline}",
"tailwind/theme-colours.blade.php:5 Tailwind outline utility `outline-none` compiles to nothing — {$outline}",
"tailwind/theme-colours.blade.php:5 Tailwind outline utility `ring-inset` compiles to nothing — {$outline}",
'tailwind/theme-colours.blade.php:5 Tailwind shadow utility `shadow-none` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS',
'tailwind/theme-colours.blade.php:5 Tailwind text utility `decoration-wavy` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind SVG utility `fill-none` compiles to nothing — write `fill` or `stroke` in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-center` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-clip-text` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-cover` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-fixed` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-no-repeat` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-top-left` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind colour utility `text-inherit` compiles to nothing — write `inherit` in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind interactivity utility `accent-auto` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind text utility `text-shadow-sm` compiles to nothing — write the rule in your own CSS',
"tailwind/theme-colours.blade.php:7 Tailwind border utility `border-bs-2` compiles to nothing — {$line}",
'tailwind/theme-colours.blade.php:7 Tailwind palette colour `bg-mauve-500` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS',
]);
});
it('leaves a Markdown mail component\'s classes and its theme to the mail theme, but still reads its icons and directives', function () {
$mail = realpath(GUARD_FIXTURES.'/mail/views');
$table = 'Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS';
$icon = 'mail/views/vendor/mail/html/sessions.blade.php:7 unknown Material Symbol `not_a_symbol`';
$directive = 'mail/views/vendor/mail/html/sessions.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`';
config(['mail.markdown.paths' => [$mail.'/vendor/mail']]);
// The theme's `.table` exempts nothing outside the mail components, and its literals are its own.
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
"mail/views/page.blade.php:1 {$table}",
$icon,
$directive,
]);
// Outside a mail component path the same files read as the application's own: the theme's
// classes as Tailwind, its literals as check (iii)'s, and its `.table` exempting `table`.
config(['mail.markdown.paths' => []]);
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
'mail/views/vendor/mail/html/sessions.blade.php:3 value outside the M3 scale `text-sm` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'mail/views/vendor/mail/html/sessions.blade.php:4 Tailwind text utility `break-all` compiles to nothing — write the rule in your own CSS',
$icon,
$directive,
'mail/views/vendor/mail/html/themes/default.css:2 literal colour `#3d4852` — use `var(--md-sys-color-*)`',
'mail/views/vendor/mail/html/themes/default.css:3 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
]);
});
it('bans the roles an application leaves out, wherever 2.0.0 writes one', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/forbidden-colours'))
->forbidColours(['tertiary', 'primary-container', 'error'])
->violations());
expect($violations)->toBe([
"forbidden-colours/app.css:3 `--md-sys-color-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/app.css:4 `--md-sys-color-on-primary-container`: role `primary-container` is not part of this application's palette",
"forbidden-colours/page.blade.php:1 `color=\"tertiary\"`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:2 `:tone=\"'tertiary'\"`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:3 `:color=\"\$failed ? 'error' : 'info'\"`: role `error` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `--md-sys-color-on-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `md-ink-error`: role `error` is not part of this application's palette",
'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
"forbidden-colours/page.blade.php:7 `--md-sys-color-primary-container`: role `primary-container` is not part of this application's palette",
]);
});
it('bans any further pattern', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views'))
->forbid('/\bfocus-ring\b/', 'a retired utility')
->violations());
expect($violations)->toContain('views/page.blade.php:6 a retired utility');
});
it('names each stylesheet the entry imports that no scanned view needs, and leaves all.css alone', function () {
// dependencies.blade.php renders <x-button>, <x-menu> and <x-split-button>: menu.css is needed
// (and reached through split-button.css too); card.css and stack.css serve nothing on the page.
$entry = realpath(GUARD_FIXTURES.'/stylesheets/unused.css');
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/unused.css:4 `components/card.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/card.css';`",
"stylesheets/unused.css:5 `layout/stack.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/layout/stack.css';`",
])
// missingStylesheets() alone never reports an unused import.
->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->missingStylesheets($entry)->violations())->toBe([])
->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))->violations())->toBe([]);
});
it('follows an entry that imports what only a Vite build resolves, reports each missing stylesheet once, and leaves the entry\'s literals to scan()', function () {
$entry = realpath(GUARD_FIXTURES.'/stylesheets/foreign-imports.css');
$mt2 = "stylesheets/views-foreign/page.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS";
$button = "stylesheets/views-foreign/page.blade.php:2 `<x-button>` needs `components/button.css`, missing from stylesheets/foreign-imports.css — add `@import '../../../../resources/css/components/button.css';`";
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'))->missingStylesheets($entry)->violations()))
->toBe([$mt2, $button])
->and(fixtureRelative(DesignGuard::scan([realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'), $entry])->missingStylesheets($entry)->violations()))
->toBe(['stylesheets/foreign-imports.css:7 literal colour `#ff0000` — use `var(--md-sys-color-*)`', $mt2, $button]);
});
it('recognises a package tag written under the configured prefix', function () {
config(['livewire-material.prefix' => 'm']);
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/prefixed.blade.php'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))
->violations());
expect($violations)->toBe([
"stylesheets/views/prefixed.blade.php:1 `<x-button>` needs `components/button.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/button.css';`",
]);
});
it('needs list-item.css for a row written by hand, and leaves a card row to card.css', function () {
// rows.blade.php writes `data-md-list-row` on an <li> and a <tr> and renders no package tag;
// card-rows.blade.php writes it only on <x-card>, whose row card.css draws, and in a selector.
$rows = realpath(GUARD_FIXTURES.'/stylesheets/views/rows.blade.php');
$cardRows = realpath(GUARD_FIXTURES.'/stylesheets/views/card-rows.blade.php');
$foundationOnly = realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css');
$entry = realpath(GUARD_FIXTURES.'/stylesheets/rows.css');
expect(fixtureRelative(DesignGuard::scan($rows)->missingStylesheets($foundationOnly)->violations()))->toBe([
"stylesheets/views/rows.blade.php:3 `data-md-list-row` needs `components/list-item.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/list-item.css';`",
])
->and(fixtureRelative(DesignGuard::scan($rows)->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/rows.css:2 `components/card.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/card.css';`",
])
->and(fixtureRelative(DesignGuard::scan($cardRows)->missingStylesheets($foundationOnly)->violations()))->toBe([
"stylesheets/views/card-rows.blade.php:1 `<x-card>` needs `components/card.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/card.css';`",
])
->and(fixtureRelative(DesignGuard::scan($cardRows)->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/rows.css:3 `components/list-item.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/list-item.css';`",
]);
});
it('reads a row written by hand in the application\'s views only, not in the package\'s own', function () {
// list-item.blade.php and card.blade.php write `data-md-list-row` for the tags that render them.
$components = realpath(__DIR__.'/../../resources/views/components');
$violations = DesignGuard::scan([$components.'/list-item.blade.php', $components.'/card.blade.php'])
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))
->violations();
expect(array_values(array_filter($violations, fn (string $violation): bool => str_contains($violation, '`data-md-list-row`'))))->toBe([]);
});
it('reports a package tag the application shadows with its own anonymous component', function () {
$views = sys_get_temp_dir().'/livewire-material-guard-shadow-'.uniqid();
File::ensureDirectoryExists($views.'/components');
File::put($views.'/components/button.blade.php', '<span>the application\'s button</span>');
View::getFinder()->prependLocation($views);
try {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/shadow.blade.php'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))
->violations());
} finally {
File::deleteDirectory($views);
}
expect($violations)->toBe([
"stylesheets/views/shadow.blade.php:1 `<x-button>` is shadowed by the application's own component of the same name — the package's `<x-button>` never renders here",
]);
});
it('reports a package tag the application shadows with its own component class', function () {
// Under a namespace of its own, which Blade's class guess reads from the application, so the
// class cannot shadow `<x-card>` for any later test in this process.
(fn () => $this->namespace = 'DesignGuardShadow\\')->call(app());
if (! class_exists('DesignGuardShadow\View\Components\Card', false)) {
eval('namespace DesignGuardShadow\View\Components; class Card {}');
}
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/shadow-class.blade.php'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))
->violations());
expect($violations)->toBe([
"stylesheets/views/shadow-class.blade.php:1 `<x-card>` is shadowed by the application's own component of the same name — the package's `<x-card>` never renders here",
]);
});
it('ignores the generated material-scheme.css, by name and by its generated header, wherever it sits among the scanned paths', function () {
$withScheme = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/app-css'))->violations());
$withoutScheme = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/app-css/app.css'))->violations());
expect($withScheme)->toBe($withoutScheme);
});
it('sorts every finding by path, then line, then message, whichever check produced it', function () {
$dir = sys_get_temp_dir().'/livewire-material-guard-sort-'.uniqid();
File::ensureDirectoryExists($dir);
$dir = realpath($dir);
// check (iii)'s application-CSS pass always runs last, after every view's own violations —
// an unsorted result would put a.css's finding after z.blade.php's, even though "a.css"
// sorts before "z.blade.php" (Finder itself already walks views alphabetically, so this is
// the one place natural order and sorted order actually diverge). One line also names a
// palette colour and a spacing utility, from two checks that run in the opposite order.
File::put($dir.'/a.css', "body {\n color: #ff0000;\n}\n");
File::put($dir.'/z.blade.php', '<div class="bg-red-500 p-4"></div>');
try {
$violations = DesignGuard::scan($dir)->violations();
} finally {
File::deleteDirectory($dir);
}
expect($violations)->toBe([
"{$dir}/a.css:2 literal colour `#ff0000` — use `var(--md-sys-color-*)`",
"{$dir}/z.blade.php:1 Tailwind palette colour `bg-red-500` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS",
"{$dir}/z.blade.php:1 Tailwind spacing utility `p-4` compiles to nothing — use `padding=\"space200\"` (16px) on `<x-surface>`, or `var(--md-sys-measurement-space200)` in your own CSS",
]);
});
it('passes the package\'s own views, showcase and Workbench, every stylesheet imported through all.css', function () {
$root = realpath(__DIR__.'/../..');
$paths = [$root.'/resources/views', $root.'/resources/js', $root.'/src', $root.'/workbench/resources'];
expect(DesignGuard::scan($paths)->missingStylesheets($root.'/resources/css/all.css')->violations())->toBe([])
// …and the stylesheet check is not vacuous there: the showcase's plain tags do need imports.
->and(DesignGuard::scan($root.'/resources/views/showcase')->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))->violations())->not->toBe([]);
});
/**
* No Tailwind-shaped class anywhere the package ships or tests itself: DesignGuard's own family
* table (check (i)) is the standing definition of a Tailwind utility or variant, so this is that
* guard turned on the package's own source. resources/views, resources/js and src are already
* covered by the previous test's scan (workbench/resources is an application fixture and carries
* Tailwind-shaped class names on purpose, so it is left out there too); this test adds what that
* one doesn't reach: tests/Browser and tests/Feature. This file and tests/Fixtures/design-guard/
* (the guard's own fixtures) carry Tailwind on purpose and are left out.
*/
it('writes no Tailwind-shaped class anywhere under tests/', function () {
$root = realpath(__DIR__.'/../..');
$designGuardTest = realpath(__DIR__.'/DesignGuardTest.php');
$paths = collect(['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([]);
});