diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index 97e64b3c..7247dd3b 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -2,28 +2,63 @@ namespace NoNameWeb\LivewireMaterial\Testing; +use Illuminate\Support\Str; +use NoNameWeb\LivewireMaterial\Support\Stylesheets; use NoNameWeb\LivewireMaterial\Support\SvgFile; use SplFileInfo; use Symfony\Component\Finder\Finder; /** - * Finds what compiles to nothing and fails silently: maryUI tags, daisyUI classes, colours the - * theme does not declare, icon names that are not Material Symbols, Tailwind's breakpoint - * 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 - * whatever an application bans on top (roles its own design rules leave out, the two absolutes, - * opacity used as emphasis, patterns it has retired). + * Finds what compiles to nothing in a Tailwind-free application (plan step 41), and what a + * Tailwind migration still leaves behind: + * + * (i) any Tailwind utility or variant in a view, PHP or JS file — a breakpoint prefix, a + * cleared scale (radius, shadow, type size/weight/leading/tracking, easing, duration), a + * 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 `` — 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()]) - * ->forbidColours(['tertiary', 'primary-container']) - * ->forbidAbsolutes() - * ->forbidOpacityInk() + * ->missingStylesheets(resource_path('css/app.css')) + * ->forbidColours(['tertiary']) * ->violations())->toBe([]); * - * Each violation is "path:line what", with the path relative to the base path; where there is - * an M3 utility to use instead, the line names it. It reads the source, so a class assembled at - * runtime (`'text-'.$tone`) is invisible to it — which is one more reason to write class names - * out whole, the only form Tailwind compiles anyway. + * Each violation is "path:line what", the path relative to the base path. False positives are + * kept low two ways: family (i)'s new checks — everything but the breakpoint, scale and + * colour-value patterns kept from before 2.0.0 — match only a class already isolated from a + * `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 { @@ -52,10 +87,12 @@ class DesignGuard protected const string OPACITY_INK = '/(? 'medium', @@ -65,7 +102,15 @@ class DesignGuard '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 = [ 'none' => 'none', 'xs' => 'xs', @@ -79,7 +124,7 @@ class DesignGuard '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 = [ '2xs' => 1, 'xs' => 1, @@ -90,6 +135,76 @@ class DesignGuard '2xl' => 5, ]; + /** + * Tailwind's flex, grid and gap utilities: ``, `` and `` 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 */ protected array $forbidden = []; @@ -100,6 +215,11 @@ class DesignGuard protected bool $forbiddenOpacityInk = false; + protected ?string $cssEntry = null; + + /** @var array|null */ + protected ?array $applicationClassesCache = null; + /** * @param list $paths */ @@ -130,7 +250,8 @@ class DesignGuard * 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 * 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 { @@ -164,12 +285,44 @@ class DesignGuard return $this; } + /** + * Check (ii): every package component tag used in a scanned view — unprefixed, under the + * configured prefix, or `` — 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 */ public function violations(): array { $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) { $contents = (string) file_get_contents($file->getPathname()); @@ -180,8 +333,14 @@ class DesignGuard $contents = $this->withoutBladeComments($contents); foreach ($this->literalClasses($contents) as [$line, $class]) { - if (preg_match(self::DAISY_CLASSES, $class) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $class) !== 1) { - $violations[] = "{$where}:{$line} daisyUI class `{$class}`"; + $bare = $this->withoutVariantPrefix($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]) { $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) { @@ -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 - * utility that replaces it. Line by line like the colour check, so a class inside a PHP or - * JS string is seen too, not only one inside a `class` attribute. + * token or component that replaces it. Line by line like the colour check, so a class inside + * 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 */ @@ -286,14 +454,17 @@ class DesignGuard return array_map(function (array $match): string { $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); } /** * 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 */ @@ -314,27 +485,27 @@ class DesignGuard preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL); 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, ); } /** - * The M3 utility for one match of the scale pattern. A type size, a leading and a tracking - * are all one type style: the `type-*` utilities set the three together, which is what keeps - * a line of text on the typescale. + * The 2.0.0 replacement for one match of the scale pattern: a token for the application's own + * CSS, or (for text) one of the `md-type-*` classes, which set size, line height, weight and + * tracking together — never a Tailwind utility, since none compiles any more. * * @param array $match */ protected function scaleReplacement(array $match): string { return match (true) { - isset($match['corner']) => '`rounded'.($match['side'] ?? '').'-corner-'.self::CORNERS[$match['corner']].'`', - isset($match['elevation']) => '`shadow-elevation-'.self::ELEVATIONS[$match['elevation']].'`', - isset($match['weight']) => 'a `type-emphasized-*` style', - isset($match['easing']) => '`ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', - isset($match['duration']) => '`duration-(--md-sys-motion-…-duration)` paired with its easing', - default => 'a `type-*` style', + isset($match['corner']) => 'use `var(--md-sys-shape-corner-'.self::CORNERS[$match['corner']].')` in your own CSS, or ``', + isset($match['elevation']) => 'use `var(--md-sys-elevation-'.self::ELEVATIONS[$match['elevation']].')` in your own CSS', + 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']) => 'pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`', + isset($match['duration']) => 'pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`', + 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; } + /** + * `$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 ``, `` or `` 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 (``, ``), 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 ``/`` 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-(?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 `` or `` for a line, not a border utility"; + } + + if (preg_match('/^bg-(?surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) { + return "Tailwind surface role `{$token}` compiles to nothing — use ``"; + } + + 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 + */ + 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 + */ + protected function literalColours(string $css): array + { + $pattern = '/(? [ + 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 + */ + 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('/(?'.$properties.')\s*:\s*(?[^;{}]+);/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('/(?[^;{}]+);/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\(|(? + */ + 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 * `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 = '/(? 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 - * `: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 */ @@ -414,7 +863,7 @@ class DesignGuard $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) { [$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) { if (preg_match('/[$@(){}]/', $token) !== 1) { - $found[] = [$line, ltrim((string) preg_replace('/^.*:/', '', $token), '!')]; + $found[] = [$line, $token]; } } } @@ -500,6 +949,295 @@ class DesignGuard return $found; } + /** + * Every package tag used in `$contents`, in any of the three spellings — unprefixed, under + * the configured prefix, or `` — 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/.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). `` is not a component tag, and + * `` is the maryUI check's concern. + * + * @return list + */ + 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/.blade.php`, found the way Blade itself falls back to it + * (the base view finder's `components.`, which `resource_path('views/components')` + * feeds by default), or an `App\View\Components` class. Either wins over this package's + * `` 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 + */ + 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 $resolved + * @return list + */ + 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, "`` is shadowed by the application's own component of the same name — the package's `` never renders here"]; + + continue; + } + + $stylesheet = static::packageStylesheetFor($name); + + if ($stylesheet !== null && ! isset($resolved[$stylesheet])) { + $found[] = [$line, sprintf( + "`` 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 + */ + 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 + */ + 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('/(?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/.css` + * or `layout/.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 { $base = rtrim(base_path(), '/').'/'; diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index 3a4f3fbf..dbe230e3 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -19,7 +19,9 @@ it('finds what compiles to nothing', function () { expect(fixtureRelative($violations))->toBe([ '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 Tailwind spacing utility `p-4` compiles to nothing — use a layout component's `gap`/`padding` prop (``, ``), or `var(--md-sys-measurement-space*)` in your own CSS", '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 ``/`` where it fits', '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: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([ - 'breakpoints/layout.blade.php:1 Tailwind breakpoint `sm:`, use `medium:`', - 'breakpoints/layout.blade.php:1 Tailwind breakpoint `md:`, use `medium:`', - 'breakpoints/layout.blade.php:1 Tailwind breakpoint `lg:`, use `expanded:`', - 'breakpoints/layout.blade.php:1 Tailwind breakpoint `xl:`, use `large:`', - 'breakpoints/layout.blade.php:1 Tailwind breakpoint `2xl:`, use `extra-large:`', - 'breakpoints/layout.blade.php:2 Tailwind breakpoint `max-sm:`, use `max-medium:`', - 'breakpoints/layout.blade.php:2 Tailwind breakpoint `max-md:`, use `max-medium:`', - 'breakpoints/layout.blade.php:2 Tailwind breakpoint `max-lg:`, use `max-expanded:`', - 'breakpoints/layout.blade.php:2 Tailwind breakpoint `max-xl:`, use `max-large:`', - 'breakpoints/layout.blade.php:2 Tailwind breakpoint `max-2xl:`, use `max-extra-large:`', - 'breakpoints/layout.blade.php:3 Tailwind breakpoint `sm:`, use `medium:`', - 'breakpoints/layout.blade.php:3 Tailwind breakpoint `md:`, 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:` 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:` 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:` 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:` 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:` 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:` 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:` 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:` 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", + ]); +}); + +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()); 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-xs`, use `rounded-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-md`, use `rounded-corner-md`', - 'scale/corners.blade.php:1 value outside the M3 scale `rounded-lg`, use `rounded-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-2xl`, use `rounded-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-4xl`, use `rounded-corner-xxl`', - 'scale/corners.blade.php:2 value outside the M3 scale `rounded-full`, use `rounded-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-se-2xl`, use `rounded-se-corner-xxl`', + 'scale/corners.blade.php:1 value outside the M3 scale `rounded-none` — use `var(--md-sys-shape-corner-none)` in your own CSS, or ``', + 'scale/corners.blade.php:1 value outside the M3 scale `rounded-xs` — use `var(--md-sys-shape-corner-xs)` in your own CSS, or ``', + 'scale/corners.blade.php:1 value outside the M3 scale `rounded-sm` — use `var(--md-sys-shape-corner-sm)` in your own CSS, or ``', + 'scale/corners.blade.php:1 value outside the M3 scale `rounded-md` — use `var(--md-sys-shape-corner-md)` in your own CSS, or ``', + 'scale/corners.blade.php:1 value outside the M3 scale `rounded-lg` — use `var(--md-sys-shape-corner-lg)` in your own CSS, or ``', + 'scale/corners.blade.php:2 value outside the M3 scale `rounded-xl` — use `var(--md-sys-shape-corner-xl)` in your own CSS, or ``', + 'scale/corners.blade.php:2 value outside the M3 scale `rounded-2xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or ``', + 'scale/corners.blade.php:2 value outside the M3 scale `rounded-3xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or ``', + 'scale/corners.blade.php:2 value outside the M3 scale `rounded-4xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or ``', + 'scale/corners.blade.php:2 value outside the M3 scale `rounded-full` — use `var(--md-sys-shape-corner-full)` in your own CSS, or ``', + '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 ``', + '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 ``', ]); }); @@ -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()); 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-xs`, use `shadow-elevation-1`', - '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-md`, use `shadow-elevation-2`', - '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-xl`, use `shadow-elevation-4`', - 'scale/elevation.blade.php:2 value outside the M3 scale `shadow-2xl`, use `shadow-elevation-5`', + '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 `var(--md-sys-elevation-1)` 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-md` — use `var(--md-sys-elevation-2)` 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', + '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()); 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-sm`, use a `type-*` style', - '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-lg`, use a `type-*` style', - '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-2xl`, use a `type-*` style', - 'scale/type.blade.php:1 value outside the M3 scale `text-9xl`, use a `type-*` style', - '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-extralight`, use a `type-emphasized-*` style', - '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-normal`, use a `type-emphasized-*` style', - 'scale/type.blade.php:2 value outside the M3 scale `font-medium`, use a `type-emphasized-*` style', - '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-bold`, use a `type-emphasized-*` style', - '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-black`, use a `type-emphasized-*` style', - '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-tight`, use a `type-*` style', - '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-normal`, use a `type-*` style', - '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-loose`, use a `type-*` style', - 'scale/type.blade.php:4 value outside the M3 scale `leading-6`, use a `type-*` style', - '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-tight`, use a `type-*` style', - '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-wide`, use a `type-*` style', - '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-widest`, 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 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-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 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: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 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-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 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: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-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 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: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 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-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 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-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-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', ]); }); @@ -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()); 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-in`, use `ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', - '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-in-out`, use `ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration', - '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-300`, use `duration-(--md-sys-motion-…-duration)` paired with its easing', - '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: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` — 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: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` — 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-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 `` or `` for a line, not a border utility', + 'tailwind/roles.blade.php:2 Tailwind surface role `bg-surface-container` compiles to nothing — use ``', + '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 ``, `` or `` 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 (``, ``), 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 ``/`` 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 (``, ``), 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 ``, `` or `` and their `gap`/`align`/`justify`/`columns` props', + ]); +}); + it('bans the two absolutes when asked', function () { $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidAbsolutes()->violations()); diff --git a/tests/Fixtures/design-guard/breakpoints/layout.blade.php b/tests/Fixtures/design-guard/breakpoints/layout.blade.php index 49601026..caa99250 100644 --- a/tests/Fixtures/design-guard/breakpoints/layout.blade.php +++ b/tests/Fixtures/design-guard/breakpoints/layout.blade.php @@ -1,5 +1,3 @@
-

stacked variants count too

- M3's own are fine
diff --git a/tests/Fixtures/design-guard/colour/badge.blade.php b/tests/Fixtures/design-guard/colour/badge.blade.php index 0cbcaeb6..52ada4fc 100644 --- a/tests/Fixtures/design-guard/colour/badge.blade.php +++ b/tests/Fixtures/design-guard/colour/badge.blade.php @@ -2,5 +2,5 @@ - M3's own are fine + M3's own are fine diff --git a/tests/Fixtures/design-guard/exempt/app.css b/tests/Fixtures/design-guard/exempt/app.css new file mode 100644 index 00000000..c1cab08f --- /dev/null +++ b/tests/Fixtures/design-guard/exempt/app.css @@ -0,0 +1,3 @@ +.gap-4 { + gap: 1rem; +} diff --git a/tests/Fixtures/design-guard/exempt/view.blade.php b/tests/Fixtures/design-guard/exempt/view.blade.php new file mode 100644 index 00000000..5baf984b --- /dev/null +++ b/tests/Fixtures/design-guard/exempt/view.blade.php @@ -0,0 +1 @@ +
diff --git a/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php b/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php new file mode 100644 index 00000000..ee97d015 --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Fixtures/design-guard/tailwind/display.blade.php b/tests/Fixtures/design-guard/tailwind/display.blade.php new file mode 100644 index 00000000..c6b22bd3 --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/display.blade.php @@ -0,0 +1,3 @@ + diff --git a/tests/Fixtures/design-guard/tailwind/layout.blade.php b/tests/Fixtures/design-guard/tailwind/layout.blade.php new file mode 100644 index 00000000..bd6a8f6d --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/layout.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Fixtures/design-guard/tailwind/roles.blade.php b/tests/Fixtures/design-guard/tailwind/roles.blade.php new file mode 100644 index 00000000..8800ca79 --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/roles.blade.php @@ -0,0 +1,2 @@ +
+ diff --git a/tests/Fixtures/design-guard/tailwind/sizing.blade.php b/tests/Fixtures/design-guard/tailwind/sizing.blade.php new file mode 100644 index 00000000..96d641a1 --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/sizing.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Fixtures/design-guard/tailwind/spacing.blade.php b/tests/Fixtures/design-guard/tailwind/spacing.blade.php new file mode 100644 index 00000000..65a9a33b --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/spacing.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Fixtures/design-guard/tailwind/text.blade.php b/tests/Fixtures/design-guard/tailwind/text.blade.php new file mode 100644 index 00000000..4f348ba5 --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/text.blade.php @@ -0,0 +1,3 @@ +

+ +

diff --git a/tests/Fixtures/design-guard/tailwind/variant.blade.php b/tests/Fixtures/design-guard/tailwind/variant.blade.php new file mode 100644 index 00000000..bae4410f --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/variant.blade.php @@ -0,0 +1,3 @@ +
+ +