Rewrite the design guard's family table for a Tailwind-free application

Plan step 41(i): every Tailwind utility or variant in a Tailwind-free
application's views now gets a 2.0.0 hint instead of the old Tailwind-utility
replacement (which no longer compiles either) -- a layout component and prop,
an md-* class, or a var(--md-sys-*) token. New families cover flex/grid/gap,
spacing, sizing, display, text-layout and the M3 role utilities
(text-on-surface-variant, border-outline-variant, bg-surface-*, bg-primary);
the breakpoint and scale checks fold in with their hints rewritten the same
way. A class the application's own stylesheets declare is exempt.

literalClasses() now returns each class token unstripped of its variant
prefix (`sm:`, `hover:`) so the new checks can see it; the daisyUI check
strips it itself via the new withoutVariantPrefix() helper, as it always did
internally before.

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 08:08:08 +02:00
co-authored by Claude Sonnet 5
parent 7ec6960731
commit 08f2ae9d76
14 changed files with 984 additions and 109 deletions
+776 -38
View File
@@ -2,28 +2,63 @@
namespace NoNameWeb\LivewireMaterial\Testing; namespace NoNameWeb\LivewireMaterial\Testing;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Support\SvgFile; use NoNameWeb\LivewireMaterial\Support\SvgFile;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
/** /**
* Finds what compiles to nothing and fails silently: maryUI tags, daisyUI classes, colours the * Finds what compiles to nothing in a Tailwind-free application (plan step 41), and what a
* theme does not declare, icon names that are not Material Symbols, Tailwind's breakpoint * Tailwind migration still leaves behind:
* prefixes and the radius, shadow, type-size, weight, leading, tracking, easing and duration *
* scales M3's own sets replace, and colours written as a literal value instead of a role — plus * (i) any Tailwind utility or variant in a view, PHP or JS file — a breakpoint prefix, a
* whatever an application bans on top (roles its own design rules leave out, the two absolutes, * cleared scale (radius, shadow, type size/weight/leading/tracking, easing, duration), a
* opacity used as emphasis, patterns it has retired). * layout, spacing, sizing or display utility, a text-layout utility, an M3 role utility
* (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary
* `[…]` value — each with its 2.0.0 replacement: a layout component and prop, an `md-*`
* class, or a token for the application's own CSS. A class the application's own
* stylesheets declare is exempt, and so is every `md-*` class and the package's shared
* interaction hooks (`md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`).
* (ii) `missingStylesheets($cssEntry)`: a package component tag used in a view — unprefixed,
* under the configured prefix, or `<x-livewire-material::…>` — whose stylesheet the
* entry's `@import` graph does not reach (followed through every package file's own
* imports, `Support\Stylesheets` resolves), and `->links()` needing `pagination.css`;
* each names the missing `@import` line to add. A tag the application shadows with its
* own component of the same name is reported instead — the application's component wins
* in Blade, so the package's stylesheet is moot.
* (iii) the application's own CSS (the entry and what it imports outside the package, plus any
* `.css` file the scanned paths hold directly), excluding the generated
* `material-scheme.css`: a literal colour, radius, shadow, font size, weight, line
* height, letter spacing, easing or duration, and a media query at a width other than
* 600/840/1200/1600px — each with its token or breakpoint. A value inside
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
* (iv) maryUI tags, daisyUI classes, colours the theme does not declare, icon names that are
* not Material Symbols, and Blade directives written inside a component tag (where they
* do not compile) — plus whatever an application bans on top with `forbidColours()`,
* `forbidAbsolutes()`, `forbidOpacityInk()` and `forbid()`. These, and the breakpoint,
* scale and colour-value checks, read a line at a time, so a class assembled at runtime
* (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason to write
* class names out whole. `forbidAbsolutes()` and `forbidOpacityInk()` matter mainly to an
* application still migrating off Tailwind, where the package's own opacity-based
* disabled state is still the only legitimate use of either.
* *
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()]) * expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
* ->forbidColours(['tertiary', 'primary-container']) * ->missingStylesheets(resource_path('css/app.css'))
* ->forbidAbsolutes() * ->forbidColours(['tertiary'])
* ->forbidOpacityInk()
* ->violations())->toBe([]); * ->violations())->toBe([]);
* *
* Each violation is "path:line what", with the path relative to the base path; where there is * Each violation is "path:line what", the path relative to the base path. False positives are
* an M3 utility to use instead, the line names it. It reads the source, so a class assembled at * kept low two ways: family (i)'s new checks — everything but the breakpoint, scale and
* runtime (`'text-'.$tone`) is invisible to it — which is one more reason to write class names * colour-value patterns kept from before 2.0.0 — match only a class already isolated from a
* out whole, the only form Tailwind compiles anyway. * `class`/`@class`/`:class`/`x-bind:class`/`->class()`/`Arr::toCssClasses()` list in a `.blade.php`
* file, never a bare word scanned across a whole line, which is what keeps an English sentence
* ("this creates a grid of cards") from matching `grid`; and a line-by-line match (used for the
* breakpoint, scale and colour-value families, which also have to see a class assembled as a
* plain PHP or JS string, as an enum's own literal colour string does) requires the utility's actual
* shape — a digit, a known scale step or a colour function — never a bare word. The trade-off:
* `Arr::toCssClasses()` and `->class()` outside a `.blade.php` file, and a bare-word utility
* (`flex`, `hidden`) anywhere but inside a class list, are invisible to this guard.
*/ */
class DesignGuard class DesignGuard
{ {
@@ -52,10 +87,12 @@ class DesignGuard
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/'; protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
/** /**
* Tailwind's breakpoint prefixes and the M3 window size class that replaces each. Tailwind's * Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
* 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/1600, so a prefix maps to the * 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/1600, so a prefix maps to the
* class that carries the same intent, never to the same pixel: both of its phone breakpoints * class that carries the same intent, never to the same pixel: both of its phone breakpoints
* are M3's medium. * are M3's medium. Since 2.0.0 the prefix itself compiles to nothing — there is no Tailwind
* left to read it — so the hint points at the layout components' props and a plain media
* query instead of another class.
*/ */
protected const array WINDOW_CLASSES = [ protected const array WINDOW_CLASSES = [
'sm' => 'medium', 'sm' => 'medium',
@@ -65,7 +102,15 @@ class DesignGuard
'2xl' => 'extra-large', '2xl' => 'extra-large',
]; ];
/** Tailwind's radius scale and the M3 corner that replaces each (styles §Shape). */ /** The M3 breakpoint each window size class is, in px (foundations.md § Layout). */
protected const array WINDOW_PX = [
'medium' => 600,
'expanded' => 840,
'large' => 1200,
'extra-large' => 1600,
];
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
protected const array CORNERS = [ protected const array CORNERS = [
'none' => 'none', 'none' => 'none',
'xs' => 'xs', 'xs' => 'xs',
@@ -79,7 +124,7 @@ class DesignGuard
'full' => 'full', 'full' => 'full',
]; ];
/** Tailwind's shadow scale and the M3 elevation level that replaces each (styles §Elevation). */ /** Tailwind's shadow scale and the M3 elevation level it replaces each (styles §Elevation). */
protected const array ELEVATIONS = [ protected const array ELEVATIONS = [
'2xs' => 1, '2xs' => 1,
'xs' => 1, 'xs' => 1,
@@ -90,6 +135,76 @@ class DesignGuard
'2xl' => 5, '2xl' => 5,
]; ];
/**
* Tailwind's flex, grid and gap utilities: `<x-row>`, `<x-grid>` and `<x-stack>` and their
* `gap`/`align`/`justify`/`columns` props replace every one of them.
*/
protected const string LAYOUT_UTILITY = '/^(?:flex|inline-flex|grid|inline-grid|contents|flex-(?:row|row-reverse|col|col-reverse|wrap|wrap-reverse|nowrap|1|auto|initial|none)|grid-cols-\d+|grid-rows-\d+|col-span-\d+|row-span-\d+|(?:items|justify|content|place-items|place-content|place-self|self)-[a-z]+|gap(?:-[xy])?-[\w.\/]+)$/';
/** Tailwind's padding, margin and gap-adjacent space-between utilities. */
protected const string SPACING_UTILITY = '/^(?:[pm][trblxyse]?|space-[xy])-[\w.\/-]+$/';
/** Tailwind's width and height utilities. M3 keeps no size scale of its own. */
protected const string SIZING_UTILITY = '/^(?:min-|max-)?(?:w|h|size)-[\w.\/%-]+$/';
/**
* Bare display utilities: no digit or bracket sets them apart from an English word, so these
* are matched only as a whole class-list token (never scanned line by line), which is what
* keeps "hidden" or "block" in running text from matching.
*/
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
/** Text-layout utilities with a direct `md-*` replacement (text.css). */
protected const array TEXT_LAYOUT_UTILITY = [
'text-left' => 'md-text-start',
'text-center' => 'md-text-center',
'text-right' => 'md-text-end',
'truncate' => 'md-truncate',
'line-clamp-2' => 'md-line-clamp-2',
'line-clamp-3' => 'md-line-clamp-3',
'whitespace-nowrap' => 'md-nowrap',
'sr-only' => 'md-visually-hidden',
'tabular-nums' => 'md-tabular',
];
/** An ink role Tailwind's `text-*` utility named, and the `md-ink-*` class that replaces it. */
protected const array INK_ROLE = [
'on-surface' => 'md-ink',
'on-surface-variant' => 'md-ink-variant',
'outline' => 'md-ink-quiet',
'primary' => 'md-ink-primary',
'error' => 'md-ink-error',
'success' => 'md-ink-success',
'warning' => 'md-ink-warning',
'info' => 'md-ink-info',
'inverse-on-surface' => 'md-ink-inverse',
];
/** The named colours a browser understands, other than `transparent` and `currentColor`. */
protected const string CSS_NAMED_COLOURS = 'aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen';
/** A property whose literal value check (iii) reports, and the token that replaces it. */
protected const array LITERAL_PROPERTIES = [
'border-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-top-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-top-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-bottom-right-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-bottom-left-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-start-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-start-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-end-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'border-end-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
'box-shadow' => ['kind' => 'shadow', 'hint' => 'use `var(--md-sys-elevation-*)`'],
'font-size' => ['kind' => 'font size', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets size, line height and weight together'],
'font-weight' => ['kind' => 'font weight', 'hint' => 'use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`'],
'line-height' => ['kind' => 'line height', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets it with the size'],
'letter-spacing' => ['kind' => 'letter spacing', 'hint' => 'use `var(--md-sys-typescale-*-tracking)`'],
'transition-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
'animation-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'],
'transition-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
'animation-duration' => ['kind' => 'duration', 'hint' => 'use `var(--md-sys-motion-…-duration)`, paired with its easing'],
];
/** @var list<array{pattern: string, reason: string}> */ /** @var list<array{pattern: string, reason: string}> */
protected array $forbidden = []; protected array $forbidden = [];
@@ -100,6 +215,11 @@ class DesignGuard
protected bool $forbiddenOpacityInk = false; protected bool $forbiddenOpacityInk = false;
protected ?string $cssEntry = null;
/** @var array<string, true>|null */
protected ?array $applicationClassesCache = null;
/** /**
* @param list<string> $paths * @param list<string> $paths
*/ */
@@ -130,7 +250,8 @@ class DesignGuard
* Also ban the two absolutes. M3 paints with roles only: the white of a light page is * Also ban the two absolutes. M3 paints with roles only: the white of a light page is
* `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled * `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled
* button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet * button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet
* sometimes does mean the absolute. * sometimes does mean the absolute — and because, until an application finishes leaving
* Tailwind, `bg-white`/`text-black` may still be sitting in an untouched view.
*/ */
public function forbidAbsolutes(): static public function forbidAbsolutes(): static
{ {
@@ -164,12 +285,44 @@ class DesignGuard
return $this; return $this;
} }
/**
* Check (ii): every package component tag used in a scanned view — unprefixed, under the
* configured prefix, or `<x-livewire-material::…>` — and every `->links()` call, against
* `$cssEntry`'s `@import` graph (followed through each package file's own imports). A missing
* one names the `@import` line to add; a tag the application shadows with its own component
* of the same name is reported instead, since the package's stylesheet is then moot. Also
* turns on check (iii), the application's own CSS: `$cssEntry` and what it imports outside
* the package become part of what that check reads, alongside any `.css` file `scan()`'s own
* paths hold directly.
*/
public function missingStylesheets(string $cssEntry): static
{
$this->cssEntry = $cssEntry;
return $this;
}
/** /**
* @return list<string> * @return list<string>
*/ */
public function violations(): array public function violations(): array
{ {
$violations = []; $violations = [];
$resolved = $this->cssEntry !== null
? array_fill_keys(Stylesheets::resolvedFiles([$this->cssEntry]), true)
: [];
if ($this->cssEntry !== null) {
$foundation = static::packageCssRoot().'/foundation.css';
if (! isset($resolved[$foundation])) {
$violations[] = sprintf(
'%s:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import \'%s\';`',
$this->relative($this->cssEntry),
$this->importLine($foundation),
);
}
}
foreach ($this->files() as $file) { foreach ($this->files() as $file) {
$contents = (string) file_get_contents($file->getPathname()); $contents = (string) file_get_contents($file->getPathname());
@@ -180,8 +333,14 @@ class DesignGuard
$contents = $this->withoutBladeComments($contents); $contents = $this->withoutBladeComments($contents);
foreach ($this->literalClasses($contents) as [$line, $class]) { foreach ($this->literalClasses($contents) as [$line, $class]) {
if (preg_match(self::DAISY_CLASSES, $class) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $class) !== 1) { $bare = $this->withoutVariantPrefix($class);
$violations[] = "{$where}:{$line} daisyUI class `{$class}`";
if (preg_match(self::DAISY_CLASSES, $bare) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $bare) !== 1) {
$violations[] = "{$where}:{$line} daisyUI class `{$bare}`";
}
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
$violations[] = "{$where}:{$line} {$hint}";
} }
} }
@@ -192,6 +351,12 @@ class DesignGuard
foreach ($this->directivesInComponentTags($contents) as [$line, $directive]) { foreach ($this->directivesInComponentTags($contents) as [$line, $directive]) {
$violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile"; $violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile";
} }
if ($this->cssEntry !== null) {
foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
} }
foreach (explode("\n", $contents) as $index => $text) { foreach (explode("\n", $contents) as $index => $text) {
@@ -221,7 +386,7 @@ class DesignGuard
} }
} }
return $violations; return [...$violations, ...$this->applicationCssViolations()];
} }
/** /**
@@ -256,8 +421,11 @@ class DesignGuard
/** /**
* Everything on one line that names a value the theme no longer carries, each with the M3 * Everything on one line that names a value the theme no longer carries, each with the M3
* utility that replaces it. Line by line like the colour check, so a class inside a PHP or * token or component that replaces it. Line by line like the colour check, so a class inside
* JS string is seen too, not only one inside a `class` attribute. * a PHP or JS string is seen too, not only one inside a `class` attribute — the reach these
* three families have always needed (a plain PHP string returning a stray colour name), and the reason they
* stay a line-by-line match rather than moving to the class-token check every newer family
* uses (see the class header).
* *
* @return list<string> * @return list<string>
*/ */
@@ -286,14 +454,17 @@ class DesignGuard
return array_map(function (array $match): string { return array_map(function (array $match): string {
$max = $match['max'] ?? ''; $max = $match['max'] ?? '';
$name = self::WINDOW_CLASSES[$match['name']];
$px = self::WINDOW_PX[$name];
$comparison = $max !== '' ? "width < {$px}px" : "width >= {$px}px";
return "Tailwind breakpoint `{$max}{$match['name']}:`, use `{$max}".self::WINDOW_CLASSES[$match['name']].':`'; return "Tailwind breakpoint `{$max}{$match['name']}:` compiles to nothing — M3's {$name} ({$px}px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media ({$comparison})` in your own CSS";
}, $matches); }, $matches);
} }
/** /**
* The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities * The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities
* Tailwind ships and M3's own scales replace. * Tailwind shipped and M3's own scales replace.
* *
* @return list<string> * @return list<string>
*/ */
@@ -314,27 +485,27 @@ class DesignGuard
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL); preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
return array_map( return array_map(
fn (array $match): string => "value outside the M3 scale `{$match[0]}`, use ".$this->scaleReplacement($match), fn (array $match): string => "value outside the M3 scale `{$match[0]}` ".$this->scaleReplacement($match),
$matches, $matches,
); );
} }
/** /**
* The M3 utility for one match of the scale pattern. A type size, a leading and a tracking * The 2.0.0 replacement for one match of the scale pattern: a token for the application's own
* are all one type style: the `type-*` utilities set the three together, which is what keeps * CSS, or (for text) one of the `md-type-*` classes, which set size, line height, weight and
* a line of text on the typescale. * tracking together — never a Tailwind utility, since none compiles any more.
* *
* @param array<array-key, string|null> $match * @param array<array-key, string|null> $match
*/ */
protected function scaleReplacement(array $match): string protected function scaleReplacement(array $match): string
{ {
return match (true) { return match (true) {
isset($match['corner']) => '`rounded'.($match['side'] ?? '').'-corner-'.self::CORNERS[$match['corner']].'`', isset($match['corner']) => 'use `var(--md-sys-shape-corner-'.self::CORNERS[$match['corner']].')` in your own CSS, or `<x-surface corner="'.self::CORNERS[$match['corner']].'">`',
isset($match['elevation']) => '`shadow-elevation-'.self::ELEVATIONS[$match['elevation']].'`', isset($match['elevation']) => 'use `var(--md-sys-elevation-'.self::ELEVATIONS[$match['elevation']].')` in your own CSS',
isset($match['weight']) => 'a `type-emphasized-*` style', isset($match['weight']) => 'use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
isset($match['easing']) => '`ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', isset($match['easing']) => 'pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
isset($match['duration']) => '`duration-(--md-sys-motion-…-duration)` paired with its easing', isset($match['duration']) => 'pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
default => 'a `type-*` style', default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
}; };
} }
@@ -373,6 +544,228 @@ class DesignGuard
return $found; return $found;
} }
/**
* `$token` with a variant prefix — a breakpoint, a stacked pseudo-class chain — and a leading
* `!` dropped, so the daisyUI check can still recognise a variant-prefixed daisy class for
* what it is underneath. `tailwindFamilyHint()` reports the prefix itself (see the variant
* family below), so it works from the token `literalClasses()` returns, unstripped.
*/
protected function withoutVariantPrefix(string $token): string
{
return ltrim((string) preg_replace('/^.*:/', '', $token), '!');
}
/**
* A Tailwind-shaped class token's 2.0.0 replacement, or null when the token is not this
* guard's to report: it is not Tailwind-shaped at all (an application's own class, an ARIA or
* data token, a plain word), it is on the small set nothing here ever flags (`md-*` — the
* text classes and the shared interaction hooks), or the application's own CSS declares it
* (`applicationClasses()`). Only ever called with a token `literalClasses()` already isolated
* from a class list, which is what makes it safe to match a bare word like `flex` or `hidden`
* (see the class header's false-positive note).
*/
protected function tailwindFamilyHint(string $token): ?string
{
if ($token === '' || str_starts_with($token, 'md-') || isset($this->applicationClasses()[$token])) {
return null;
}
$token = rtrim($token, '!');
if (preg_match(self::ARBITRARY_COLOUR, $token) === 1) {
return null; // colourValues() already reports this, line by line.
}
if (str_contains($token, ':')) {
$lead = explode(':', $token, 2)[0];
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
if (isset(self::WINDOW_CLASSES[$bare])) {
return null; // breakpointPrefixes() already names the M3 breakpoint for this one.
}
return "Tailwind variant class `{$token}` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop";
}
if (preg_match('/\[[^\]]*\]/', $token) === 1) {
return "Tailwind arbitrary value `{$token}` compiles to nothing — write the literal value in your own CSS, or use an M3 token";
}
if (preg_match(self::LAYOUT_UTILITY, $token) === 1) {
return "Tailwind layout utility `{$token}` compiles to nothing — use `<x-row>`, `<x-grid>` or `<x-stack>` and their `gap`/`align`/`justify`/`columns` props";
}
if (preg_match(self::SPACING_UTILITY, $token) === 1) {
return "Tailwind spacing utility `{$token}` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS";
}
if (preg_match(self::SIZING_UTILITY, $token) === 1) {
return "Tailwind sizing utility `{$token}` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits";
}
if ($token === 'hidden') {
return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, or the reset's `[hidden]` attribute";
}
if (in_array($token, self::DISPLAY_UTILITY, true)) {
return "Tailwind display utility `{$token}` compiles to nothing — write the `display` rule in your own CSS";
}
if (isset(self::TEXT_LAYOUT_UTILITY[$token])) {
return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)';
}
if (preg_match('/^text-(?<role>on-surface(?:-variant)?|outline|primary|error|success|warning|info|inverse-on-surface)$/', $token, $m) === 1) {
return "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)';
}
if (preg_match('/^(?:border(?:-[trblxyse])?|divide|ring)-outline(?:-variant)?$/', $token) === 1) {
return "Tailwind line role `{$token}` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility";
}
if (preg_match('/^bg-(?<role>surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) {
return "Tailwind surface role `{$token}` compiles to nothing — use `<x-surface level=\"{$m['role']}\">`";
}
if (preg_match('/^(?:bg|border(?:-[trblxyse])?|ring|fill|stroke|divide|decoration|accent|caret|outline)-(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container)?$/', $token) === 1) {
return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS";
}
return null;
}
/**
* Check (iii): literal design values in the application's own CSS. `var(--md-sys-…)` and
* `calc()` are blanked out first (see `withoutTokenFunctions()`), so a value built from tokens
* is invisible to every pattern below, whatever it contains; `0`, `none` and `inherit` are
* always fine.
*
* @return list<string>
*/
protected function applicationCssViolations(): array
{
$violations = [];
foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutTokenFunctions($this->withoutCssComments((string) file_get_contents($file)));
$where = $this->relative($file);
foreach ([
...$this->literalColours($css),
...$this->literalDeclarations($css),
...$this->offScaleMediaQueries($css),
] as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
}
}
return $violations;
}
/**
* Every literal colour in `$css`: a hex code, a colour function, or a named colour other than
* `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) —
* which is what keeps a selector like `.red-banner` from matching.
*
* @return list<array{0: int, 1: string}>
*/
protected function literalColours(string $css): array
{
$pattern = '/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab|color)\(|(?<=[:,(]\s{0,20})(?:'.self::CSS_NAMED_COLOURS.')\b(?!-))/i';
preg_match_all($pattern, $css, $matches, PREG_OFFSET_CAPTURE);
return array_map(
fn (array $match): array => [
substr_count(substr($css, 0, $match[1]), "\n") + 1,
"literal colour `{$match[0]}` — use `var(--md-sys-color-*)`",
],
$matches[0],
);
}
/**
* Every literal value on a property check (iii) knows, plus a `transition`/`animation`
* shorthand's embedded easing or duration (its property name alone cannot say which part of
* the value is which, so both are searched for whenever either is written literally).
*
* @return list<array{0: int, 1: string}>
*/
protected function literalDeclarations(string $css): array
{
$properties = implode('|', array_map(fn (string $p): string => preg_quote($p, '/'), array_keys(self::LITERAL_PROPERTIES)));
preg_match_all('/(?<property>'.$properties.')\s*:\s*(?<value>[^;{}]+);/i', $css, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($matches as $match) {
$property = strtolower($match['property'][0]);
$value = trim($match['value'][0]);
if ($this->isSafeLiteralValue($value)) {
continue;
}
$line = substr_count(substr($css, 0, $match[0][1]), "\n") + 1;
$config = self::LITERAL_PROPERTIES[$property];
$found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"];
}
foreach (['transition', 'animation'] as $shorthand) {
preg_match_all('/(?<![\w-])'.$shorthand.'\s*:\s*(?<value>[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE);
foreach ($shorthandMatches['value'] as [$value, $offset]) {
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
if (preg_match('/cubic-bezier\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|linear)(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal easing in `{$shorthand}: {$value}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
if (preg_match('/(?<![\w.-])\d+(?:\.\d+)?m?s(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal duration in `{$shorthand}: {$value}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
}
}
}
return $found;
}
protected function isSafeLiteralValue(string $value): bool
{
return trim($value) === '' || in_array(strtolower(trim($value)), ['0', '0px', '0s', 'none', 'inherit', 'initial', 'unset', 'normal', 'auto'], true);
}
/**
* Every `@media` width in `$css` that is not one of M3's four breakpoints written in px.
*
* @return list<array{0: int, 1: string}>
*/
protected function offScaleMediaQueries(string $css): array
{
preg_match_all('/@media\s*([^{]*)\{/i', $css, $matches, PREG_OFFSET_CAPTURE);
$found = [];
foreach ($matches[1] as [$prelude, $preludeOffset]) {
preg_match_all('/(\d+(?:\.\d+)?)(px|rem|em)\b/i', $prelude, $widths, PREG_OFFSET_CAPTURE);
foreach ($widths[0] as $i => [$whole, $widthOffset]) {
$number = (float) $widths[1][$i][0];
$unit = strtolower($widths[2][$i][0]);
if ($unit === 'px' && in_array((int) $number, [600, 840, 1200, 1600], true) && $number == (int) $number) {
continue;
}
$line = substr_count(substr($css, 0, $preludeOffset + $widthOffset), "\n") + 1;
$found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)"];
}
}
return $found;
}
/** /**
* The names as one alternation, longest first so that a prefix of another name — `xl` of * The names as one alternation, longest first so that a prefix of another name — `xl` of
* `2xl` — cannot win where the pattern is not anchored. * `2xl` — cannot win where the pattern is not anchored.
@@ -398,9 +791,65 @@ class DesignGuard
); );
} }
/**
* `$css` with every `/* … *\/` comment blanked out, line breaks kept.
*/
protected function withoutCssComments(string $css): string
{
return (string) preg_replace_callback(
'/\/\*.*?\*\//s',
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
$css,
);
}
/**
* `$css` with the argument list of every `var(…)` and `calc(…)` call blanked out (nested
* parentheses tracked, so a `calc(var(--x) + 4px)` inside a `var()` fallback stays hidden
* too) — what lets check (iii) treat any value built from a token as fine, whatever literal
* numbers or colours it wraps.
*/
protected function withoutTokenFunctions(string $css): string
{
return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), 'calc');
}
protected function maskFunctionCalls(string $css, string $name): string
{
$pattern = '/(?<![\w-])'.$name.'\(/i';
$offset = 0;
while (preg_match($pattern, $css, $match, PREG_OFFSET_CAPTURE, $offset) === 1) {
$open = $match[0][1] + strlen($match[0][0]) - 1;
$depth = 1;
$i = $open + 1;
$length = strlen($css);
while ($i < $length && $depth > 0) {
if ($css[$i] === '(') {
$depth++;
} elseif ($css[$i] === ')') {
$depth--;
}
$i++;
}
$inner = substr($css, $open + 1, $i - $open - 2);
$blank = (string) preg_replace('/[^\n]/', ' ', $inner);
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
$offset = $i;
}
return $css;
}
/** /**
* Every class written out literally: in `class="…"`, and in the string keys and values of * Every class written out literally: in `class="…"`, and in the string keys and values of
* `:class`, `@class([...])` and `->class([...])`. Echoes inside a list are skipped. * `:class`/`x-bind:class`, `@class([...])`, `->class([...])` and `Arr::toCssClasses([...])`.
* Echoes inside a list are skipped. A token is returned exactly as written, variant prefix
* (`sm:`, `hover:`) and all — `withoutVariantPrefix()` strips one for the daisyUI check, which
* needs to see through it; `tailwindFamilyHint()` needs to see it.
* *
* @return list<array{0: int, 1: string}> * @return list<array{0: int, 1: string}>
*/ */
@@ -414,7 +863,7 @@ class DesignGuard
$lists[] = [$list, $offset]; $lists[] = [$list, $offset];
} }
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\)|Arr::toCssClasses\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
foreach ($bindings as $binding) { foreach ($bindings as $binding) {
[$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0]; [$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0];
@@ -434,7 +883,7 @@ class DesignGuard
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) { foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) {
if (preg_match('/[$@(){}]/', $token) !== 1) { if (preg_match('/[$@(){}]/', $token) !== 1) {
$found[] = [$line, ltrim((string) preg_replace('/^.*:/', '', $token), '!')]; $found[] = [$line, $token];
} }
} }
} }
@@ -500,6 +949,295 @@ class DesignGuard
return $found; return $found;
} }
/**
* Every package tag used in `$contents`, in any of the three spellings — unprefixed, under
* the configured prefix, or `<x-livewire-material::…>` — each as [line, name, spelling].
* `spelling` is `plain`, `prefix` or `namespace`: only `plain` can be shadowed by an
* application component of the same name (Blade tries the application's own
* `resources/views/components/<name>.blade.php` — and a class-based one — before this
* package's registered anonymous path, whatever the configured prefix; a namespaced or
* prefixed tag always reaches the package). `<x-slot:…>` is not a component tag, and
* `<x-mary-…>` is the maryUI check's concern.
*
* @return list<array{0: int, 1: string, 2: string}>
*/
protected function packageTagUsages(string $contents): array
{
$prefix = (string) config('livewire-material.prefix', '');
preg_match_all('/<(x-[\w.:-]+)(?=[\s\/>])/', $contents, $tags, PREG_OFFSET_CAPTURE);
$found = [];
foreach ($tags[1] as [$tag, $offset]) {
if (str_starts_with($tag, 'x-slot') || str_starts_with($tag, 'x-mary-')) {
continue;
}
$name = substr($tag, 2);
$spelling = 'plain';
if (str_contains($name, '::')) {
[$namespace, $name] = explode('::', $name, 2);
if ($namespace === 'livewire-material') {
$spelling = 'namespace';
} elseif ($prefix !== '' && $namespace === $prefix) {
$spelling = 'prefix';
} else {
continue;
}
} elseif (! static::isPackageTag($name)) {
continue;
}
$found[] = [substr_count(substr($contents, 0, $offset), "\n") + 1, $name, $spelling];
}
return $found;
}
/**
* Whether the application defines its own component of this name — an anonymous
* `resources/views/components/<name>.blade.php`, found the way Blade itself falls back to it
* (the base view finder's `components.<name>`, which `resource_path('views/components')`
* feeds by default), or an `App\View\Components` class. Either wins over this package's
* `<x-{$name}>` in Blade's own resolution order.
*/
protected function shadowedByApplication(string $name): bool
{
return view()->exists('components.'.$name) || class_exists('App\\View\\Components\\'.Str::studly($name));
}
/**
* Every `->links()` call in `$contents` (Laravel's or Livewire's paginator, rendered outside
* a package component), each as the line it is on.
*
* @return list<int>
*/
protected function paginationUsages(string $contents): array
{
preg_match_all('/->links\s*\(/', $contents, $matches, PREG_OFFSET_CAPTURE);
return array_map(fn (array $match): int => substr_count(substr($contents, 0, $match[1]), "\n") + 1, $matches[0]);
}
/**
* Check (ii)'s findings for one file: a package tag whose stylesheet `$resolved` (every file
* `Stylesheets::resolvedFiles()` reached from the CSS entry, keyed by real path) does not
* contain, a tag the application shadows, and a `->links()` needing `pagination.css`.
*
* @param array<string, true> $resolved
* @return list<array{0: int, 1: string}>
*/
protected function missingStylesheetViolations(string $contents, array $resolved): array
{
$found = [];
foreach ($this->packageTagUsages($contents) as [$line, $name, $spelling]) {
if ($spelling === 'plain' && $this->shadowedByApplication($name)) {
$found[] = [$line, "`<x-{$name}>` is shadowed by the application's own component of the same name — the package's `<x-{$name}>` never renders here"];
continue;
}
$stylesheet = static::packageStylesheetFor($name);
if ($stylesheet !== null && ! isset($resolved[$stylesheet])) {
$found[] = [$line, sprintf(
"`<x-%s>` needs `%s`, missing from %s — add `@import '%s';`",
$name,
static::packageRelativeName($stylesheet),
$this->relative($this->cssEntry),
$this->importLine($stylesheet),
)];
}
}
$pagination = static::packageCssRoot().'/components/pagination.css';
foreach ($this->paginationUsages($contents) as $line) {
if (! isset($resolved[$pagination])) {
$found[] = [$line, sprintf(
"`->links()` needs `components/pagination.css`, missing from %s — add `@import '%s';`",
$this->relative($this->cssEntry),
$this->importLine($pagination),
)];
}
}
return $found;
}
/**
* Every `.css` file this guard reads for check (iii): any the scanned paths hold directly,
* plus — once `missingStylesheets()` names a CSS entry — every file its `@import` graph
* reaches outside the package. The generated `material-scheme.css` is excluded either way.
*
* @return list<string>
*/
protected function applicationStylesheets(): array
{
$files = [];
foreach ($this->paths as $path) {
if (is_file($path)) {
if (str_ends_with($path, '.css')) {
$files[] = (string) realpath($path);
}
continue;
}
if (is_dir($path)) {
foreach (Finder::create()->files()->in($path)->name('*.css')->sortByName() as $file) {
$files[] = $file->getRealPath();
}
}
}
if ($this->cssEntry !== null) {
array_push($files, ...Stylesheets::resolvedFiles([$this->cssEntry]));
}
return array_values(array_unique(array_filter(
$files,
fn (string $file): bool => $file !== '' && ! static::isPackageFile($file) && ! $this->isGeneratedScheme($file),
)));
}
/**
* Every class an application's own stylesheets select on — check (i)'s exemption list, so a
* class it defines is never reported as a dead Tailwind utility just because it happens to
* share a shape with one.
*
* @return array<string, true>
*/
protected function applicationClasses(): array
{
if ($this->applicationClassesCache !== null) {
return $this->applicationClassesCache;
}
$classes = [];
foreach ($this->applicationStylesheets() as $file) {
$css = $this->withoutCssComments((string) file_get_contents($file));
preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches);
foreach ($matches[1] as $class) {
$classes[$class] = true;
}
}
return $this->applicationClassesCache = $classes;
}
protected function isGeneratedScheme(string $file): bool
{
if (basename($file) === 'material-scheme.css') {
return true;
}
return str_contains((string) file_get_contents($file), "generated by Google's material-color-utilities");
}
/**
* Whether `$file` sits inside this package's own `resources/css` — the application's stylesheets
* (check iii) and its exempt classes never come from the package's own rules.
*/
protected static function isPackageFile(string $file): bool
{
$root = static::packageCssRoot();
return $root !== '' && ($file === $root || str_starts_with($file, $root.'/'));
}
/**
* This package's own `resources/css`, from this file's own location — works whether the class
* loads from `vendor/nonameweb/livewire-material` (a consuming application) or from this
* repository itself, the same trick `Support\Stylesheets::name()` uses.
*/
protected static function packageCssRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/css');
}
/**
* This package's own `resources/views/components` — every component and layout component
* lives here, so a tag's own view existing there is what "is this a package tag" checks.
*/
protected static function packageComponentViewRoot(): string
{
return (string) realpath(dirname(__DIR__, 2).'/resources/views/components');
}
protected static function isPackageTag(string $name): bool
{
$root = static::packageComponentViewRoot();
return $root !== '' && is_file("{$root}/{$name}.blade.php");
}
/**
* The package stylesheet a component view of this name draws from — `components/<name>.css`
* or `layout/<name>.css`, whichever exists — mapped from the files that exist rather than a
* hand-kept list. Null when the view has no stylesheet of its own (`theme-script`) or the
* name is not a package component at all.
*/
protected static function packageStylesheetFor(string $name): ?string
{
if (! static::isPackageTag($name)) {
return null;
}
foreach (['components', 'layout'] as $group) {
$file = static::packageCssRoot()."/{$group}/{$name}.css";
if (is_file($file)) {
return $file;
}
}
return null;
}
protected static function packageRelativeName(string $file): string
{
$root = static::packageCssRoot().'/';
return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file;
}
/**
* The `@import` target `$file` should be written as, from the CSS entry's own directory —
* computed from the real paths, so it reads `../vendor/nonameweb/livewire-material/…` from a
* consuming application and `../resources/css/…` inside this repository, whichever is real.
*/
protected function importLine(string $file): string
{
$entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry));
return static::relativeImportPath($entryDir, $file);
}
protected static function relativeImportPath(string $fromDir, string $toFile): string
{
$from = array_values(array_filter(explode('/', $fromDir), fn (string $part): bool => $part !== ''));
$to = array_values(array_filter(explode('/', $toFile), fn (string $part): bool => $part !== ''));
$i = 0;
while ($i < count($from) && $i < count($to) && $from[$i] === $to[$i]) {
$i++;
}
$path = implode('/', [...array_fill(0, count($from) - $i, '..'), ...array_slice($to, $i)]);
return str_starts_with($path, '..') ? $path : "./{$path}";
}
protected function relative(string $path): string protected function relative(string $path): string
{ {
$base = rtrim(base_path(), '/').'/'; $base = rtrim(base_path(), '/').'/';
+180 -68
View File
@@ -19,7 +19,9 @@ it('finds what compiles to nothing', function () {
expect(fixtureRelative($violations))->toBe([ expect(fixtureRelative($violations))->toBe([
'app/Status.php:14 colour the theme does not declare `text-red-500`', 'app/Status.php:14 colour the theme does not declare `text-red-500`',
'views/page.blade.php:3 daisyUI class `card`', 'views/page.blade.php:3 daisyUI class `card`',
"views/page.blade.php:3 Tailwind spacing utility `p-4` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS",
'views/page.blade.php:6 daisyUI class `btn-primary`', 'views/page.blade.php:6 daisyUI class `btn-primary`',
'views/page.blade.php:9 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits',
'views/page.blade.php:4 unknown Material Symbol `o-home`', 'views/page.blade.php:4 unknown Material Symbol `o-home`',
'views/page.blade.php:5 unknown Material Symbol `not_a_symbol`', 'views/page.blade.php:5 unknown Material Symbol `not_a_symbol`',
'views/page.blade.php:9 Blade directive `@class` inside a component tag, where it does not compile', 'views/page.blade.php:9 Blade directive `@class` inside a component tag, where it does not compile',
@@ -29,20 +31,30 @@ it('finds what compiles to nothing', function () {
]); ]);
}); });
it('names the M3 window size class for a Tailwind breakpoint', function () { it('names the M3 breakpoint for a Tailwind prefix, with its 2.0.0 replacement', function () {
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/breakpoints'))->violations()))->toBe([ expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/breakpoints'))->violations()))->toBe([
'breakpoints/layout.blade.php:1 Tailwind breakpoint `sm:`, use `medium:`', "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 `md:`, use `medium:`', "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 `lg:`, use `expanded:`', "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 `xl:`, use `large:`', "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:1 Tailwind breakpoint `2xl:`, use `extra-large:`', "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:2 Tailwind breakpoint `max-sm:`, use `max-medium:`', "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-md:`, use `max-medium:`', "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-lg:`, use `max-expanded:`', "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-xl:`, use `max-large:`', "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",
'breakpoints/layout.blade.php:2 Tailwind breakpoint `max-2xl:`, use `max-extra-large:`', "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:3 Tailwind breakpoint `sm:`, use `medium:`', ]);
'breakpoints/layout.blade.php:3 Tailwind breakpoint `md:`, use `medium:`', });
it('names the 2.0.0 replacement for a pseudo-class variant, but leaves a breakpoint prefix to its own finding', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/variant.blade.php'))->violations());
expect($violations)->toBe([
"tailwind/variant.blade.php:1 Tailwind variant class `hover:underline` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop",
"tailwind/variant.blade.php:1 Tailwind variant class `dark:opacity-50` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop",
"tailwind/variant.blade.php:2 Tailwind variant class `group-hover:flex` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop",
"tailwind/variant.blade.php:2 Tailwind variant class `focus:md:gap-2` compiles to nothing — a state is `:has()`/`aria-*`/`data-md-*` in your own CSS, a breakpoint a layout component's `hide-below`/`hide-from`/`stack-below` prop",
"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",
]); ]);
}); });
@@ -56,18 +68,18 @@ it('names the M3 corner for a Tailwind radius', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/corners.blade.php'))->violations()); $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/corners.blade.php'))->violations());
expect($violations)->toBe([ expect($violations)->toBe([
'scale/corners.blade.php:1 value outside the M3 scale `rounded-none`, use `rounded-corner-none`', '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-xs`, use `rounded-corner-xs`', '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:1 value outside the M3 scale `rounded-sm`, use `rounded-corner-sm`', '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-md`, use `rounded-corner-md`', '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-lg`, use `rounded-corner-lg`', '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:2 value outside the M3 scale `rounded-xl`, use `rounded-corner-xl`', '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:2 value outside the M3 scale `rounded-2xl`, use `rounded-corner-xxl`', '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 `rounded-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 `rounded-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 `rounded-corner-full`', '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:3 value outside the M3 scale `rounded-t-lg`, use `rounded-t-corner-lg`', '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">`',
'scale/corners.blade.php:3 value outside the M3 scale `rounded-se-2xl`, use `rounded-se-corner-xxl`', '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">`',
]); ]);
}); });
@@ -75,13 +87,13 @@ it('names the M3 elevation level for a Tailwind shadow', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/elevation.blade.php'))->violations()); $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/elevation.blade.php'))->violations());
expect($violations)->toBe([ expect($violations)->toBe([
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-2xs`, use `shadow-elevation-1`', '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-xs`, use `shadow-elevation-1`', '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:1 value outside the M3 scale `shadow-sm`, use `shadow-elevation-1`', '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-md`, use `shadow-elevation-2`', '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:2 value outside the M3 scale `shadow-lg`, use `shadow-elevation-3`', '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 `shadow-elevation-4`', 'scale/elevation.blade.php:2 value outside the M3 scale `shadow-xl` use `var(--md-sys-elevation-4)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-2xl`, use `shadow-elevation-5`', 'scale/elevation.blade.php:2 value outside the M3 scale `shadow-2xl` use `var(--md-sys-elevation-5)` in your own CSS',
]); ]);
}); });
@@ -89,35 +101,35 @@ it('sends a size, a weight, a leading and a tracking to a type style', function
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/type.blade.php'))->violations()); $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/type.blade.php'))->violations());
expect($violations)->toBe([ expect($violations)->toBe([
'scale/type.blade.php:1 value outside the M3 scale `text-xs`, use a `type-*` style', '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:1 value outside the M3 scale `text-sm`, use a `type-*` style', '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-base`, use a `type-*` style', '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 a `type-*` style', '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-xl`, use a `type-*` style', '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-2xl`, use a `type-*` style', '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 a `type-*` style', '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:2 value outside the M3 scale `font-thin`, use a `type-emphasized-*` style', '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:2 value outside the M3 scale `font-extralight`, use a `type-emphasized-*` style', '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 a `type-emphasized-*` style', '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-normal`, use a `type-emphasized-*` style', '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-medium`, use a `type-emphasized-*` style', '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:3 value outside the M3 scale `font-semibold`, use a `type-emphasized-*` style', '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:3 value outside the M3 scale `font-bold`, use a `type-emphasized-*` style', '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 a `type-emphasized-*` style', '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-black`, use a `type-emphasized-*` style', '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:4 value outside the M3 scale `leading-none`, use a `type-*` style', '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-tight`, use a `type-*` style', '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:4 value outside the M3 scale `leading-snug`, use a `type-*` style', '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-normal`, use a `type-*` style', '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 a `type-*` style', '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-loose`, use a `type-*` style', '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-6`, use a `type-*` style', '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:5 value outside the M3 scale `tracking-tighter`, use a `type-*` style', '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-tight`, use a `type-*` style', '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-normal`, use a `type-*` style', '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-wide`, use a `type-*` style', '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 a `type-*` style', '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 a `type-*` style', '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',
]); ]);
}); });
@@ -125,13 +137,13 @@ it('sends an easing and a duration to the motion tokens', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/motion.blade.php'))->violations()); $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/motion.blade.php'))->violations());
expect($violations)->toBe([ expect($violations)->toBe([
'scale/motion.blade.php:1 value outside the M3 scale `ease-linear`, use `ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', '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-in`, use `ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', '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-out`, use `ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', '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:1 value outside the M3 scale `ease-in-out`, use `ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', '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:2 value outside the M3 scale `duration-75`, use `duration-(--md-sys-motion-…-duration)` paired with its easing', '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.blade.php:2 value outside the M3 scale `duration-300`, use `duration-(--md-sys-motion-…-duration)` paired with its easing', '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-1000`, use `duration-(--md-sys-motion-…-duration)` paired with its easing', '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`',
]); ]);
}); });
@@ -145,6 +157,106 @@ it('finds a colour written as a value', function () {
]); ]);
}); });
it('names the md-ink-*, divider/surface or token replacement for a dead M3 role utility', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/roles.blade.php'))->violations());
expect($violations)->toBe([
'tailwind/roles.blade.php:1 Tailwind ink role `text-on-surface-variant` compiles to nothing — use `md-ink-variant` (text.css)',
'tailwind/roles.blade.php:1 Tailwind line role `border-outline-variant` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility',
'tailwind/roles.blade.php:2 Tailwind surface role `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`',
'tailwind/roles.blade.php:2 Tailwind role utility `bg-primary` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS',
]);
});
it('sends flex, grid and gap utilities to the layout components', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/layout.blade.php'))->violations());
$hint = 'compiles to nothing — use `<x-row>`, `<x-grid>` or `<x-stack>` and their `gap`/`align`/`justify`/`columns` props';
expect($violations)->toBe([
"tailwind/layout.blade.php:1 Tailwind layout utility `flex` {$hint}",
"tailwind/layout.blade.php:1 Tailwind layout utility `grid` {$hint}",
"tailwind/layout.blade.php:1 Tailwind layout utility `inline-flex` {$hint}",
"tailwind/layout.blade.php:1 Tailwind layout utility `flex-col` {$hint}",
"tailwind/layout.blade.php:2 Tailwind layout utility `grid-cols-3` {$hint}",
"tailwind/layout.blade.php:2 Tailwind layout utility `col-span-2` {$hint}",
"tailwind/layout.blade.php:2 Tailwind layout utility `items-center` {$hint}",
"tailwind/layout.blade.php:2 Tailwind layout utility `justify-between` {$hint}",
"tailwind/layout.blade.php:2 Tailwind layout utility `gap-4` {$hint}",
]);
});
it('sends padding, margin and space-between utilities to a layout component\'s gap/padding prop', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/spacing.blade.php'))->violations());
$hint = "compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS";
expect($violations)->toBe([
"tailwind/spacing.blade.php:1 Tailwind spacing utility `p-4` {$hint}",
"tailwind/spacing.blade.php:1 Tailwind spacing utility `px-2` {$hint}",
"tailwind/spacing.blade.php:1 Tailwind spacing utility `mt-2` {$hint}",
"tailwind/spacing.blade.php:1 Tailwind spacing utility `space-y-4` {$hint}",
"tailwind/spacing.blade.php:2 Tailwind spacing utility `m-4` {$hint}",
"tailwind/spacing.blade.php:2 Tailwind spacing utility `space-x-2` {$hint}",
]);
});
it('sends width and height utilities to a literal length, since M3 keeps no size scale', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/sizing.blade.php'))->violations());
$hint = 'compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits';
expect($violations)->toBe([
"tailwind/sizing.blade.php:1 Tailwind sizing utility `w-full` {$hint}",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `h-screen` {$hint}",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `min-w-0` {$hint}",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `max-w-md` {$hint}",
"tailwind/sizing.blade.php:2 Tailwind sizing utility `size-4` {$hint}",
]);
});
it('sends hidden to hide-below/hide-from and every other display utility to a plain CSS rule', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/display.blade.php'))->violations());
expect($violations)->toBe([
"tailwind/display.blade.php:1 Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, or the reset's `[hidden]` attribute",
'tailwind/display.blade.php:1 Tailwind display utility `block` compiles to nothing — write the `display` rule in your own CSS',
'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',
]);
});
it('sends text-layout utilities to their md-* class', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/text.blade.php'))->violations());
expect($violations)->toBe([
"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 `sr-only` compiles to nothing — use `md-visually-hidden` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `whitespace-nowrap` compiles to nothing — use `md-nowrap` (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 `tabular-nums` compiles to nothing — use `md-tabular` (text.css)",
]);
});
it('exempts a class the application\'s own CSS declares, even when it is Tailwind-shaped', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/exempt'))->violations());
expect($violations)->toBe([
"exempt/view.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS",
]);
});
it('reports an arbitrary [] value once, whatever utility it modifies, and strips a trailing !', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/arbitrary.blade.php'))->violations());
expect($violations)->toBe([
'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: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:2 Tailwind layout utility `flex` compiles to nothing — use `<x-row>`, `<x-grid>` or `<x-stack>` and their `gap`/`align`/`justify`/`columns` props',
]);
});
it('bans the two absolutes when asked', function () { it('bans the two absolutes when asked', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidAbsolutes()->violations()); $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidAbsolutes()->violations());
@@ -1,5 +1,3 @@
<div class="sm:flex md:grid lg:hidden xl:block 2xl:contents"> <div class="sm:flex md:grid lg:hidden xl:block 2xl:contents">
<span class="max-sm:hidden max-md:flex max-lg:grid max-xl:block max-2xl:contents"></span> <span class="max-sm:hidden max-md:flex max-lg:grid max-xl:block max-2xl:contents"></span>
<p class="sm:hover:underline focus:md:gap-2">stacked variants count too</p>
<span class="medium:flex expanded:grid large:block extra-large:contents max-medium:hidden">M3's own are fine</span>
</div> </div>
@@ -2,5 +2,5 @@
<span class="fill-[oklch(0.7_0.1_250)] ring-[color-mix(in_oklab,var(--x)_50%,transparent)]"></span> <span class="fill-[oklch(0.7_0.1_250)] ring-[color-mix(in_oklab,var(--x)_50%,transparent)]"></span>
<span class="bg-white text-black border-white"></span> <span class="bg-white text-black border-white"></span>
<span class="text-on-surface/60 bg-on-surface/12 border-outline/38"></span> <span class="text-on-surface/60 bg-on-surface/12 border-outline/38"></span>
<span class="bg-primary text-on-primary border-outline-variant">M3's own are fine</span> <span class="md-ink md-type-body-md">M3's own are fine</span>
</div> </div>
@@ -0,0 +1,3 @@
.gap-4 {
gap: 1rem;
}
@@ -0,0 +1 @@
<div class="gap-4 mt-2"></div>
@@ -0,0 +1,3 @@
<div class="top-[10px] grid-cols-[1fr_2fr]">
<span class="flex!"></span>
</div>
@@ -0,0 +1,3 @@
<div class="hidden block">
<span class="inline-block invisible"></span>
</div>
@@ -0,0 +1,3 @@
<div class="flex grid inline-flex flex-col">
<span class="grid-cols-3 col-span-2 items-center justify-between gap-4"></span>
</div>
@@ -0,0 +1,2 @@
<div class="text-on-surface-variant border-outline-variant"></div>
<span class="bg-surface-container bg-primary"></span>
@@ -0,0 +1,3 @@
<div class="w-full h-screen min-w-0 max-w-md">
<span class="size-4"></span>
</div>
@@ -0,0 +1,3 @@
<div class="p-4 px-2 mt-2 space-y-4">
<span class="m-4 space-x-2"></span>
</div>
@@ -0,0 +1,3 @@
<p class="text-center truncate">
<span class="sr-only whitespace-nowrap line-clamp-2 tabular-nums"></span>
</p>
@@ -0,0 +1,3 @@
<div class="hover:underline dark:opacity-50">
<span class="group-hover:flex focus:md:gap-2"></span>
</div>