From 9595722f35053843f3b4ac5f3bfee9dcbfd15d65 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 08:43:29 +0200 Subject: [PATCH] Close the design guard's family-table gaps on real application markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 41 review: run over SealShare's and ReStride's views, family (i) missed about a quarter of the class tokens, most of them 1.x's own utilities (type-body-md, text-meta, rounded-corner-lg, focus-ring, link) and common Tailwind families (relative, inset-0, z-10, overflow-hidden, border, opacity-50, shrink-0, -mx-4, cursor-pointer, font-mono). Each now has a hint, and the ones with a layout component name its prop: gap-4 is gap="space200" (16px), a step between two of M3's names both, items-center align="center", grid-cols-3 :columns="3"; variants say which selector or media query replaces them. Shapes are tightened to real Tailwind values, so an application class such as content-header or p-card is not matched. Class lists are also read from wire:*.class, x-transition:*, unquoted :class keys, double-quoted PHP strings, 'class' => '…' and plain PHP files; a string compared in a condition (view === 'grid') is skipped, and each token carries its own line in a multi-line list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- src/Testing/DesignGuard.php | 291 ++++++++++++++---- tests/Feature/DesignGuardTest.php | 141 ++++++--- .../design-guard/scale/corners.blade.php | 2 +- .../design-guard/scale/elevation.blade.php | 2 +- .../design-guard/scale/motion.blade.php | 2 +- .../design-guard/scale/type.blade.php | 2 +- .../design-guard/tailwind/arbitrary.blade.php | 1 + .../design-guard/tailwind/bindings.blade.php | 8 + .../design-guard/tailwind/one-x.blade.php | 3 + .../design-guard/tailwind/utilities.blade.php | 4 + 10 files changed, 348 insertions(+), 108 deletions(-) create mode 100644 tests/Fixtures/design-guard/tailwind/bindings.blade.php create mode 100644 tests/Fixtures/design-guard/tailwind/one-x.blade.php create mode 100644 tests/Fixtures/design-guard/tailwind/utilities.blade.php diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index 27a39a77..aeb7ccbb 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -3,6 +3,7 @@ namespace NoNameWeb\LivewireMaterial\Testing; use Illuminate\Support\Str; +use NoNameWeb\LivewireMaterial\Support\Layout; use NoNameWeb\LivewireMaterial\Support\Stylesheets; use NoNameWeb\LivewireMaterial\Support\SvgFile; use SplFileInfo; @@ -14,12 +15,13 @@ use Symfony\Component\Finder\Finder; * * (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-*` + * layout, spacing, sizing, position, border, effect, interactivity, text or display + * utility, a colour utility on a role, a 1.x ink, white or black (`text-meta`, `bg-white`, + * `text-on-surface/60`), a 1.x package utility (`type-body-md`, `focus-ring`, + * `rounded-corner-lg`), a variant, or an arbitrary `[…]` value or property — each with its + * 2.0.0 replacement: a layout component and prop (`gap-4` → `gap="space200"`), 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`). + * stylesheets declare is exempt, and so is every `md-*` class. * (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 @@ -46,16 +48,16 @@ use Symfony\Component\Finder\Finder; * ->violations())->toBe([]); * * 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. + * kept low two ways: family (i)'s bare-word checks match only a class already isolated from a + * class list in a `.php` file (`class="…"`, `wire:loading.class`, `x-transition:enter`, `:class`, + * `@class`, `->class()`, `Arr::toCssClasses()`, `'class' => '…'`), never a word scanned across a + * whole line — which keeps "this creates a grid of cards" from matching `grid` — and skip a string + * a condition compares (`view === 'grid'`); a line-by-line match (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: a bare-word utility + * (`flex`, `hidden`) anywhere but inside a class list, and an application's own Tailwind theme + * names (`bg-brand`), are invisible to this guard. */ class DesignGuard { @@ -116,33 +118,59 @@ class DesignGuard ]; /** - * Tailwind's flex, grid and gap utilities: ``, `` and `` and their - * `gap`/`align`/`justify`/`columns` props replace every one of them. + * Every other Tailwind utility family an application's views wrote, as [pattern, hint] pairs + * tried in order on a class-list token (so a bare word like `flex` is only ever matched there). + * `%s` in a hint is the token. The spacing, container and text families with a finer hint are + * handled before this table (see `tailwindFamilyHint()`); the breakpoint, scale and palette + * families are the line-by-line checks'. */ - 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.\/]+)$/'; + protected const array FAMILIES = [ + // 1.x's own utilities, which compiled through the package's Tailwind theme until 2.0.0. + ['/^(?:focus-ring|state-layer|touch-target|link)$/', '1.x utility `%s` compiles to nothing — use `md-%s` (interaction.css)'], + ['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-corner-(?[a-z-]+)$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-{corner})` in your own CSS, or ``'], + ['/^shadow-elevation-(?[0-5])$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-elevation-{level})` in your own CSS'], + ['/^ease-(?(?:spatial|effects)-(?:fast|default|slow))$/', '1.x utility `%s` compiles to nothing — pair `var(--md-sys-motion-{easing})` with `var(--md-sys-motion-{easing}-duration)` in your own `transition`'], + ['/^ease-(?standard|emphasized)(?-accelerate|-decelerate)?$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-motion-easing-{easing}{phase})` in your own `transition`'], + ['/^[a-z][a-z-]*-\((?--[\w-]+)\)$/', 'Tailwind custom-property utility `%s` compiles to nothing — write `var({property})` in your own CSS'], - /** Tailwind's padding, margin and gap-adjacent space-between utilities. */ - protected const string SPACING_UTILITY = '/^(?:[pm][trblxyse]?|space-[xy])-[\w.\/-]+$/'; + ['/^(?:static|fixed|absolute|relative|sticky)$/', "Tailwind position utility `%s` compiles to nothing — write `position` in your own CSS (a FAB goes in ``'s `fab` slot)"], + ['/^-?(?:inset(?:-[xyse])?|top|right|bottom|left|start|end)-(?:\d+(?:\.\d+)?|px|full|auto|\d+\/\d+)$/', 'Tailwind inset utility `%s` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step'], + ['/^-?z-(?:\d+|auto)$/', 'Tailwind z-index utility `%s` compiles to nothing — write `z-index` in your own CSS'], + ['/^(?:overflow|overscroll)(?:-[xy])?-(?:auto|hidden|clip|visible|scroll|contain|none)$/', 'Tailwind overflow utility `%s` compiles to nothing — write `overflow` in your own CSS'], + ['/^(?:flex-(?:1|auto|initial|none|row-reverse|col-reverse|nowrap|wrap-reverse)|grow(?:-\d+)?|shrink(?:-\d+)?|basis-[\w.\/]+|order-(?:\d+|first|last|none)|contents|grid-(?:rows|cols)-(?:none|subgrid|\d+)|(?:col|row)-(?:span-(?:\d+|full)|start-\d+|end-\d+|auto)|grid-flow-[\w-]+|auto-(?:cols|rows)-[\w]+|justify-(?:around|evenly|stretch|normal|items-[\w-]+|self-[\w-]+)|(?:content|place-content|place-items|place-self|self)-(?:start|end|center|between|around|evenly|stretch|baseline|normal|auto|none))$/', "Tailwind flex/grid item utility `%s` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS"], + ['/^(?:min-|max-)?(?:w|h|size)-(?:\d+(?:\.\d+)?|\d+\/\d+|px|full|screen|auto|min|max|fit|dvh|svh|lvh|dvw|svw|lvw|lh|prose|none|3xs|2xs|xs|sm|md|lg|xl|[2-7]xl|screen-(?:sm|md|lg|xl|2xl))$/', 'Tailwind sizing utility `%s` compiles to nothing — M3 keeps no size scale: `` sets a content column\'s measure, `` an icon\'s; anything else is a length in your own CSS'], + ['/^container$/', 'Tailwind\'s `container` compiles to nothing — use ``, which sets M3\'s margins and a measure'], + ['/^(?:table(?:-[a-z-]+)?|flow-root|list-item|inline-table)$/', 'Tailwind display utility `%s` compiles to nothing — write the `display` rule in your own CSS'], + ['/^(?:border(?:-[trblxyse])?(?:-\d+)?|border-(?:solid|dashed|dotted|double|hidden|none)|divide-[xy](?:-\d+|-reverse)?|divide-(?:solid|dashed|dotted|double|none))$/', 'Tailwind border utility `%s` compiles to nothing — a line is `` or ``; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`'], + ['/^rounded(?:-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?$/', 'Tailwind radius utility `%s` compiles to nothing — use `var(--md-sys-shape-corner-xs)` in your own CSS, or ``'], + ['/^(?:shadow|shadow-none|inset-shadow(?:-[\w]+)?|drop-shadow(?:-[\w]+)?)$/', 'Tailwind shadow utility `%s` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS'], + ['/^(?:outline(?:-none|-hidden|-\d+|-offset-\d+|-dashed|-dotted|-double|-solid)?|ring(?:-\d+|-inset)?|ring-offset-\d+)$/', 'Tailwind outline utility `%s` compiles to nothing — M3\'s focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS'], + ['/^(?:transition(?:-(?:all|colors|opacity|shadow|transform|none|discrete))?|animate-[\w-]+)$/', 'Tailwind motion utility `%s` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`'], + ['/^opacity-\d+$/', 'Tailwind opacity utility `%s` compiles to nothing — write `opacity` in your own CSS (M3\'s disabled content is 38 %%)'], + ['/^(?:transform(?:-none|-gpu|-cpu)?|-?(?:scale|rotate|skew-[xy]|translate-[xy]|scale-[xy])-[\w.\/]+|origin-[\w-]+|will-change-[\w-]+|blur(?:-\w+)?|backdrop-[\w-]+|mix-blend-[\w-]+|isolate|isolation-auto)$/', 'Tailwind effect utility `%s` compiles to nothing — write the rule in your own CSS'], + ['/^(?:cursor-[\w-]+|pointer-events-(?:none|auto)|select-(?:none|text|all|auto)|touch-[\w-]+|resize(?:-[xy]|-none)?|appearance-(?:none|auto)|scroll-(?:smooth|auto)|-?scroll-[mp][trblxyse]?-[\w.]+|snap-[\w-]+)$/', 'Tailwind interactivity utility `%s` compiles to nothing — write the rule in your own CSS'], + ['/^(?:aspect-(?:auto|square|video|\d+\/\d+)|object-(?:contain|cover|fill|none|scale-down|top|bottom|center|left|right|left-top|left-bottom|right-top|right-bottom))$/', 'Tailwind media utility `%s` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS'], + ['/^font-(?:sans|serif)$/', 'Tailwind\'s `%s` compiles to nothing — the foundation already sets the brand typeface; any other `font-family` is your own CSS'], + ['/^(?:underline|no-underline)$/', 'Tailwind\'s `%s` compiles to nothing — `md-link` draws a link (interaction.css); any other decoration is your own CSS'], + ['/^(?:uppercase|lowercase|capitalize|normal-case|italic|not-italic|overline|line-through|underline-offset-\w+|decoration-(?:\d+|solid|double|dotted|dashed|wavy|auto|from-font|clone|slice)|font-mono|antialiased|subpixel-antialiased|whitespace-(?:normal|pre|pre-line|pre-wrap|break-spaces)|break-(?:words|all|keep|normal)|wrap-(?:break-word|anywhere|normal)|text-(?:wrap|balance|pretty|ellipsis|clip|justify)|line-clamp-(?:\d+|none)|not-sr-only|list-(?:disc|decimal|none|inside|outside)|align-(?:baseline|top|middle|bottom|text-top|text-bottom|sub|super)|indent-[\w.]+|hyphens-(?:none|manual|auto)|(?:normal|lining|oldstyle|proportional)-nums|ordinal|slashed-zero|(?:diagonal|stacked)-fractions)$/', 'Tailwind text utility `%s` compiles to nothing — write the rule in your own CSS'], + ['/^bg-(?:(?:gradient-to|linear-to)-[a-z]+|radial|conic|cover|contain|auto|center|top|bottom|left|right|no-repeat|repeat(?:-[xy])?|fixed|local|scroll|none|clip-[a-z]+|origin-[a-z]+)$/', 'Tailwind background utility `%s` compiles to nothing — write the rule in your own CSS'], + ]; - /** 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. - */ + /** Bare display utilities, matched only as a whole class-list token. */ protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible']; - /** Text-layout utilities with a direct `md-*` replacement (text.css). */ + /** Text utilities with a direct `md-*` replacement (text.css). */ protected const array TEXT_LAYOUT_UTILITY = [ 'text-left' => 'md-text-start', + 'text-start' => 'md-text-start', 'text-center' => 'md-text-center', 'text-right' => 'md-text-end', + 'text-end' => 'md-text-end', 'truncate' => 'md-truncate', 'line-clamp-2' => 'md-line-clamp-2', 'line-clamp-3' => 'md-line-clamp-3', 'whitespace-nowrap' => 'md-nowrap', + 'text-nowrap' => 'md-nowrap', 'sr-only' => 'md-visually-hidden', 'tabular-nums' => 'md-tabular', ]; @@ -314,13 +342,17 @@ class DesignGuard if ($isBlade) { $contents = $this->withoutBladeComments($contents); + } + if (str_ends_with($file->getFilename(), '.php')) { foreach ($this->literalClasses($contents) as [$line, $class]) { if (($hint = $this->tailwindFamilyHint($class)) !== null) { $violations[] = "{$where}:{$line} {$hint}"; } } + } + if ($isBlade) { foreach ($this->iconNames($contents) as [$line, $name]) { $violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`"; } @@ -563,10 +595,11 @@ class DesignGuard * 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). + * text classes and the shared interaction hooks), the application's own CSS declares it + * (`applicationClasses()`), or a line-by-line check reports it already (a breakpoint prefix, a + * cleared scale step, an arbitrary or palette colour). 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 { @@ -574,41 +607,40 @@ class DesignGuard return null; } - $token = rtrim($token, '!'); + $token = trim($token, '!'); - if (preg_match(self::ARBITRARY_COLOUR, $token) === 1) { - return null; // colourValues() already reports this, line by line. + if (preg_match(self::ARBITRARY_COLOUR, $token) === 1 || preg_match($this->colourPattern(), $token) === 1 || $this->outsideTheScale($token) !== []) { + return null; // colourValues(), the palette check and outsideTheScale() already report this, line by line. } - if (str_contains($token, ':')) { - $lead = explode(':', $token, 2)[0]; - $bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead; + if (preg_match('/^(?\[[^\]]*\]|[^:\[]+(?:\[[^\]]*\])?):/', $token, $variant) === 1) { + $bare = str_starts_with($variant['lead'], 'max-') ? substr($variant['lead'], 4) : $variant['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"; + return "Tailwind variant class `{$token}` compiles to nothing — ".$this->variantHint($variant['lead']); } - if (preg_match('/\[[^\]]*\]/', $token) === 1) { + if (preg_match('/^(?:type-(?:emphasized-)?(?:display|headline|title|body|label)-(?:lg|md|sm))$/', $token) === 1) { + return "1.x utility `{$token}` compiles to nothing — use `md-{$token}` (text.css)"; + } + + if (preg_match('/^\[(?-{0,2}[a-z][\w-]*):[^\]]+\]$/', $token, $arbitrary) === 1) { + return "Tailwind arbitrary property `{$token}` compiles to nothing — write `{$arbitrary['property']}` in your own CSS"; + } + + if (preg_match('/^-?[a-z][\w-]*-\[[^\]]*\]$/', $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 (($hint = $this->containerHint($token) ?? $this->spacingHint($token)) !== null) { + return $hint; } 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"; + return "Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`"; } if (in_array($token, self::DISPLAY_UTILITY, true)) { @@ -619,7 +651,119 @@ class DesignGuard return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)'; } - return $this->colourUtilityHint($token); + if (($hint = $this->colourUtilityHint($token)) !== null) { + return $hint; + } + + foreach (self::FAMILIES as [$pattern, $hint]) { + if (preg_match($pattern, $token, $match) === 1) { + return (string) preg_replace_callback( + '/\{(\w+)\}/', + fn (array $name): string => (string) ($match[$name[1]] ?? ''), + str_replace('%%', '%', str_replace('%s', $token, $hint)), + ); + } + } + + return null; + } + + /** + * The 2.0.0 replacement for a Tailwind variant, by its first segment: a state, the theme, a + * structural pseudo-class, a direction, an M3 breakpoint variant 1.x compiled, or an + * arbitrary selector. + */ + protected function variantHint(string $lead): string + { + $m3 = ['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600]; + $bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead; + + return match (true) { + isset($m3[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<')." {$m3[$bare]}px)` in your own CSS, or a layout component's `hide-below`/`hide-from`/`stack-below` prop", + $lead === 'dark' => "the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS", + $lead === 'rail-collapsed' => "`:root[data-rail='collapsed'] …` in your own CSS", + in_array($lead, ['motion-reduce', 'motion-safe'], true) => '`@media (prefers-reduced-motion: reduce)` in your own CSS', + in_array($lead, ['rtl', 'ltr'], true) => "`:is([dir='rtl'], [dir='rtl'] *)` in your own CSS", + $lead === 'print' => '`@media print` in your own CSS', + preg_match('/^(?:hover|focus|focus-visible|focus-within|active|pressed)$/', $lead) === 1 => "M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS", + preg_match('/^(?:group|peer|has|not|in)(?:-|\/|$)/', $lead) === 1 => '`:has()` or a descendant selector in your own CSS', + preg_match('/^(?:aria|data)-/', $lead) === 1 => 'an `[aria-…]` or `[data-…]` attribute selector in your own CSS', + preg_match('/^(?:before|after|placeholder|file|marker|selection|backdrop|first-line|first-letter)$/', $lead) === 1 => 'a pseudo-element rule in your own CSS', + str_starts_with($lead, '[') => 'write the arbitrary selector as a rule in your own CSS', + default => 'write the state or condition as a selector or `@media` rule in your own CSS', + }; + } + + /** + * The layout component and prop for a Tailwind flex or grid container utility, or null. + */ + protected function containerHint(string $token): ?string + { + $dead = "Tailwind layout utility `{$token}` compiles to nothing"; + + return match (true) { + in_array($token, ['flex', 'inline-flex', 'flex-row'], true) => "{$dead} — use `` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `` for a column", + $token === 'flex-col' => "{$dead} — use `` (`gap`, `align`)", + $token === 'flex-wrap' => "{$dead} — use ``", + in_array($token, ['grid', 'inline-grid'], true) => "{$dead} — use `` (`:columns` per breakpoint, `gap`, `min-item`), or `` for a grid of cards", + preg_match('/^grid-cols-(\d+)$/', $token, $m) === 1 => "{$dead} — use ``, or a per-breakpoint map (`:columns=\"['compact' => 1, 'medium' => {$m[1]}]\"`)", + preg_match('/^items-(start|end|center|stretch)$/', $token, $m) === 1 => "{$dead} — use `align=\"{$m[1]}\"` on `` or ``", + $token === 'items-baseline' => "{$dead} — use `align=\"baseline\"` on ``", + preg_match('/^justify-(start|end|center|between)$/', $token, $m) === 1 => "{$dead} — use `justify=\"{$m[1]}\"` on ``", + default => null, + }; + } + + /** + * The M3 spacing step for a Tailwind gap, padding, margin or space-between utility, or null. + * Tailwind's spacing unit is 4px (`gap-4` is 16px, `space200`); a value between two of M3's + * steps names both neighbours. + */ + protected function spacingHint(string $token): ?string + { + if (preg_match('/^(?-)?(?gap(?:-[xy])?|[pm][trblxyse]?|space-[xy])-(?\d+(?:\.\d+)?|px|auto|reverse)$/', $token, $m) !== 1) { + return null; + } + + $dead = "Tailwind spacing utility `{$token}` compiles to nothing"; + + if ($m['value'] === 'auto') { + return in_array($m['kind'], ['m', 'mx'], true) + ? "{$dead} — `` centres its content; otherwise `margin-inline: auto` in your own CSS" + : "{$dead} — an `auto` margin is a rule in your own CSS"; + } + + if ($m['value'] === 'reverse') { + return "{$dead} — a reversed row is a `flex-direction` rule in your own CSS"; + } + + $px = $m['value'] === 'px' ? 1.0 : (float) $m['value'] * 4; + $steps = array_combine(Layout::SPACING, array_map( + fn (string $step): float => (float) substr($step, 5) / 100 * 8, + Layout::SPACING, + )); + $exact = array_search($px, $steps, true); + $below = array_key_last(array_filter($steps, fn (float $step): bool => $step < $px)); + $above = array_key_first(array_filter($steps, fn (float $step): bool => $step > $px)); + $pxText = rtrim(rtrim(number_format($px, 2, '.', ''), '0'), '.').'px'; + + $choice = fn (string $attribute): string => $exact !== false + ? "`{$attribute}=\"{$exact}\"` ({$pxText})" + : implode(' or ', array_map( + fn (string $step): string => "`{$attribute}=\"{$step}\"` (".rtrim(rtrim(number_format($steps[$step], 2, '.', ''), '0'), '.').'px)', + array_values(array_filter([$below, $above])), + ))." — {$pxText} is not an M3 spacing step"; + $token = $exact !== false ? "`var(--md-sys-measurement-{$exact})`" : '`var(--md-sys-measurement-space*)`'; + + return match (true) { + $px === 0.0 => "{$dead} — leave it out: the reset zeroes margins and padding", + $m['negative'] === '-' => "{$dead} — a negative margin is `calc(-1 * ".trim($token, '`').')` in your own CSS', + str_starts_with($m['kind'], 'gap') => "{$dead} — use ".$choice('gap').' on ``, ``, `` or ``', + $m['kind'] === 'space-y' => "{$dead} — use `` with ".$choice('gap'), + $m['kind'] === 'space-x' => "{$dead} — use `` with ".$choice('gap'), + str_starts_with($m['kind'], 'p') => "{$dead} — use ".$choice('padding')." on ``, or {$token} in your own CSS", + default => "{$dead} — space between siblings is a layout component's ".$choice('gap')."; any other margin is {$token} in your own CSS", + }; } /** @@ -897,10 +1041,14 @@ class DesignGuard } /** - * Every class written out literally: in `class="…"`, and in the string keys and values of - * `: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, since `tailwindFamilyHint()` reports the prefix itself. + * Every class written out literally: in `class="…"`, Livewire's `wire:loading.class="…"` and + * Alpine's `x-transition:enter="…"` lists, and in the strings of `:class`/`x-bind:class` (its + * object keys too, quoted or not), `@class([...])`, `->class(...)`, `Arr::toCssClasses([...])` + * and a `'class' => '…'` pair. A string compared in a condition (`view === 'grid'`, + * `$status === 'hidden'`) or passed to a call (`isActive('grid')`) is not a class; echoes + * inside a list are skipped. A token is returned exactly as written, variant prefix (`sm:`, + * `hover:`) and all, since `tailwindFamilyHint()` reports the prefix itself, with the line it + * sits on. * * @return list */ @@ -908,33 +1056,42 @@ class DesignGuard { $lists = []; - preg_match_all('/(?class\(\[(.*?)\]\)|Arr::toCssClasses\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + preg_match_all('/:class="(?[^"]*)"|(?:@class|->class|Arr::toCssClasses)\((?\[.*?\]|\'[^\']*\'|"[^"]*")\)|([\'"])class\3\s*=>\s*(?\'[^\']*\'|"[^"]*")/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL); foreach ($bindings as $binding) { - [$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0]; + $alpine = $binding['alpine'][0] !== null; + [$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']); - preg_match_all("/'([^']*)'/", $body, $strings, PREG_OFFSET_CAPTURE); + preg_match_all($alpine ? "/'([^']*)'|(?<=[{,])\\s*([A-Za-z_][\\w]*)\\s*:/" : "/'([^']*)'|\"([^\"]*)\"/", $body, $strings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL); + + foreach ($strings as $string) { + [$list, $inner] = $string[1][0] !== null ? $string[1] : $string[2]; + $before = substr($body, 0, $string[0][1]); + $after = substr($body, $string[0][1] + strlen($string[0][0])); + + if (preg_match('/(?:[=!]==?|[<>]=?|\(|\?\?)\s*$/', $before) === 1 || preg_match('/^\s*(?:[=!]==?|[<>]=?)/', $after) === 1) { + continue; + } - foreach ($strings[1] as [$list, $inner]) { $lists[] = [$list, $offset + $inner]; } } $found = []; + usort($lists, fn (array $a, array $b): int => $a[1] <=> $b[1]); foreach ($lists as [$list, $offset]) { - $line = substr_count(substr($contents, 0, $offset), "\n") + 1; - $list = (string) preg_replace('/\{\{.*?\}\}|\{!!.*?!!\}/', ' ', $list); + $list = (string) preg_replace_callback('/\{\{.*?\}\}|\{!!.*?!!\}/s', fn (array $echo): string => (string) preg_replace('/[^\n]/', ' ', $echo[0]), $list); - foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) { - if (preg_match('/[$@(){}]/', $token) !== 1) { - $found[] = [$line, $token]; + foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE) ?: [] as [$token, $at]) { + if (preg_match('/[$@{}\\\\]/', $token) !== 1 && substr_count($token, '(') === substr_count($token, ')')) { + $found[] = [substr_count(substr($contents, 0, $offset + $at), "\n") + 1, $token]; } } } diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index a148c9a2..e150bb1a 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -23,9 +23,10 @@ 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:2 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:2 Tailwind spacing utility `p-4` compiles to nothing — use `padding="space200"` (16px) on ``, or `var(--md-sys-measurement-space200)` in your own CSS', 'views/page.blade.php:6 Tailwind colour utility `text-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS', - 'views/page.blade.php:8 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:6 1.x utility `focus-ring` compiles to nothing — use `md-focus-ring` (interaction.css)', + "views/page.blade.php:8 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale: `` sets a content column's measure, `` an icon's; anything else is a length in your own CSS", 'views/page.blade.php:3 unknown Material Symbol `o-home`', 'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`', 'views/page.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile', @@ -52,10 +53,10 @@ it('names the 2.0.0 replacement for a pseudo-class variant, but leaves a breakpo $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:1 Tailwind variant class `hover:underline` compiles to nothing — M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS", + "tailwind/variant.blade.php:1 Tailwind variant class `dark:opacity-50` compiles to nothing — the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS", + 'tailwind/variant.blade.php:2 Tailwind variant class `group-hover:flex` compiles to nothing — `:has()` or a descendant selector in your own CSS', + "tailwind/variant.blade.php:2 Tailwind variant class `focus:md:gap-2` compiles to nothing — M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS", "tailwind/variant.blade.php:2 Tailwind 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", ]); }); @@ -189,50 +190,44 @@ it('names the md-ink-*, divider/surface or token replacement for a dead M3 role ]); }); -it('sends flex, grid and gap utilities to the layout components', function () { +it('sends flex, grid and gap utilities to the layout component and prop that replaces each', 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}", + 'tailwind/layout.blade.php:1 Tailwind layout utility `flex` compiles to nothing — use `` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `` for a column', + 'tailwind/layout.blade.php:1 Tailwind layout utility `grid` compiles to nothing — use `` (`:columns` per breakpoint, `gap`, `min-item`), or `` for a grid of cards', + 'tailwind/layout.blade.php:1 Tailwind layout utility `inline-flex` compiles to nothing — use `` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `` for a column', + 'tailwind/layout.blade.php:1 Tailwind layout utility `flex-col` compiles to nothing — use `` (`gap`, `align`)', + "tailwind/layout.blade.php:2 Tailwind layout utility `grid-cols-3` compiles to nothing — use ``, or a per-breakpoint map (`:columns=\"['compact' => 1, 'medium' => 3]\"`)", + "tailwind/layout.blade.php:2 Tailwind flex/grid item utility `col-span-2` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS", + 'tailwind/layout.blade.php:2 Tailwind layout utility `items-center` compiles to nothing — use `align="center"` on `` or ``', + 'tailwind/layout.blade.php:2 Tailwind layout utility `justify-between` compiles to nothing — use `justify="between"` on ``', + 'tailwind/layout.blade.php:2 Tailwind spacing utility `gap-4` compiles to nothing — use `gap="space200"` (16px) on ``, ``, `` or ``', ]); }); -it('sends padding, margin and space-between utilities to a layout component\'s gap/padding prop', function () { +it('sends padding, margin and space-between utilities to the M3 spacing step they are (gap-4 is space200)', 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}", + 'tailwind/spacing.blade.php:1 Tailwind spacing utility `p-4` compiles to nothing — use `padding="space200"` (16px) on ``, or `var(--md-sys-measurement-space200)` in your own CSS', + 'tailwind/spacing.blade.php:1 Tailwind spacing utility `px-2` compiles to nothing — use `padding="space100"` (8px) on ``, or `var(--md-sys-measurement-space100)` in your own CSS', + "tailwind/spacing.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS", + 'tailwind/spacing.blade.php:1 Tailwind spacing utility `space-y-4` compiles to nothing — use `` with `gap="space200"` (16px)', + "tailwind/spacing.blade.php:2 Tailwind spacing utility `m-4` compiles to nothing — space between siblings is a layout component's `gap=\"space200\"` (16px); any other margin is `var(--md-sys-measurement-space200)` in your own CSS", + 'tailwind/spacing.blade.php:2 Tailwind spacing utility `space-x-2` compiles to nothing — use `` with `gap="space100"` (8px)', ]); }); 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}", + "tailwind/sizing.blade.php:1 Tailwind sizing utility `w-full` compiles to nothing — M3 keeps no size scale: `` sets a content column's measure, `` an icon's; anything else is a length in your own CSS", + "tailwind/sizing.blade.php:1 Tailwind sizing utility `h-screen` compiles to nothing — M3 keeps no size scale: `` sets a content column's measure, `` an icon's; anything else is a length in your own CSS", + "tailwind/sizing.blade.php:1 Tailwind sizing utility `min-w-0` compiles to nothing — M3 keeps no size scale: `` sets a content column's measure, `` an icon's; anything else is a length in your own CSS", + "tailwind/sizing.blade.php:1 Tailwind sizing utility `max-w-md` compiles to nothing — M3 keeps no size scale: `` sets a content column's measure, `` an icon's; anything else is a length in your own CSS", + "tailwind/sizing.blade.php:2 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale: `` sets a content column's measure, `` an icon's; anything else is a length in your own CSS", ]); }); @@ -240,7 +235,7 @@ it('sends hidden to hide-below/hide-from and every other display utility to a pl $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's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`", 'tailwind/display.blade.php: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', @@ -264,7 +259,7 @@ it('exempts a class the application\'s own CSS declares, even when it is Tailwin $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", + "exempt/view.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS", ]); }); @@ -274,7 +269,9 @@ it('reports an arbitrary [] value once, whatever utility it modifies, and strips 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', + 'tailwind/arbitrary.blade.php:2 Tailwind layout utility `flex` compiles to nothing — use `` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `` for a column', + 'tailwind/arbitrary.blade.php:4 Tailwind arbitrary property `[--material-bottom-bar:5rem]` compiles to nothing — write `--material-bottom-bar` in your own CSS', + "tailwind/arbitrary.blade.php:4 Tailwind arbitrary property `[font-variation-settings:'ROND'_100]` compiles to nothing — write `font-variation-settings` in your own CSS", ]); }); @@ -304,6 +301,76 @@ it('leaves every role alone until an application forbids one', function () { ]); }); +it('names the md-* class or token for a 1.x utility that compiled through the package\'s Tailwind theme', function () { + $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/one-x.blade.php'))->violations()); + + expect($violations)->toBe([ + 'tailwind/one-x.blade.php:1 1.x utility `type-body-md` compiles to nothing — use `md-type-body-md` (text.css)', + 'tailwind/one-x.blade.php:1 1.x utility `type-emphasized-title-lg` compiles to nothing — use `md-type-emphasized-title-lg` (text.css)', + 'tailwind/one-x.blade.php:1 1.x utility `focus-ring` compiles to nothing — use `md-focus-ring` (interaction.css)', + 'tailwind/one-x.blade.php:1 1.x utility `state-layer` compiles to nothing — use `md-state-layer` (interaction.css)', + 'tailwind/one-x.blade.php:1 1.x utility `link` compiles to nothing — use `md-link` (interaction.css)', + 'tailwind/one-x.blade.php:2 1.x utility `rounded-corner-lg` compiles to nothing — use `var(--md-sys-shape-corner-lg)` in your own CSS, or ``', + 'tailwind/one-x.blade.php:2 1.x utility `rounded-t-corner-xl-increased` compiles to nothing — use `var(--md-sys-shape-corner-xl-increased)` in your own CSS, or ``', + 'tailwind/one-x.blade.php:2 1.x utility `shadow-elevation-2` compiles to nothing — use `var(--md-sys-elevation-2)` in your own CSS', + 'tailwind/one-x.blade.php:2 1.x utility `ease-spatial-fast` compiles to nothing — pair `var(--md-sys-motion-spatial-fast)` with `var(--md-sys-motion-spatial-fast-duration)` in your own `transition`', + 'tailwind/one-x.blade.php:2 1.x utility `ease-emphasized-decelerate` compiles to nothing — use `var(--md-sys-motion-easing-emphasized-decelerate)` in your own `transition`', + 'tailwind/one-x.blade.php:2 Tailwind custom-property utility `duration-(--md-sys-motion-spatial-fast-duration)` compiles to nothing — write `var(--md-sys-motion-spatial-fast-duration)` in your own CSS', + ]); +}); + +it('reports position, border, effect, interactivity and text utilities with the rule to write instead', function () { + $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/utilities.blade.php'))->violations()); + + expect($violations)->toBe([ + "tailwind/utilities.blade.php:1 Tailwind position utility `relative` compiles to nothing — write `position` in your own CSS (a FAB goes in ``'s `fab` slot)", + 'tailwind/utilities.blade.php:1 Tailwind inset utility `inset-0` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step', + 'tailwind/utilities.blade.php:1 Tailwind inset utility `-top-2` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step', + 'tailwind/utilities.blade.php:1 Tailwind z-index utility `z-10` compiles to nothing — write `z-index` in your own CSS', + 'tailwind/utilities.blade.php:1 Tailwind overflow utility `overflow-hidden` compiles to nothing — write `overflow` in your own CSS', + "tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `shrink-0` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS", + "tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `col-start-2` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS", + "tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `justify-self-end` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS", + "tailwind/utilities.blade.php:1 Tailwind's `container` compiles to nothing — use ``, which sets M3's margins and a measure", + 'tailwind/utilities.blade.php:2 Tailwind border utility `border` compiles to nothing — a line is `` or ``; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`', + 'tailwind/utilities.blade.php:2 Tailwind border utility `border-t-2` compiles to nothing — a line is `` or ``; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`', + 'tailwind/utilities.blade.php:2 Tailwind radius utility `rounded` compiles to nothing — use `var(--md-sys-shape-corner-xs)` in your own CSS, or ``', + 'tailwind/utilities.blade.php:2 Tailwind shadow utility `shadow` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS', + "tailwind/utilities.blade.php:2 Tailwind outline utility `outline-none` compiles to nothing — M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS", + "tailwind/utilities.blade.php:2 Tailwind outline utility `ring-2` compiles to nothing — M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS", + 'tailwind/utilities.blade.php:2 Tailwind motion utility `transition` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`', + "tailwind/utilities.blade.php:2 Tailwind opacity utility `opacity-50` compiles to nothing — write `opacity` in your own CSS (M3's disabled content is 38 %)", + 'tailwind/utilities.blade.php:2 Tailwind effect utility `-rotate-45` compiles to nothing — write the rule in your own CSS', + 'tailwind/utilities.blade.php:2 Tailwind interactivity utility `cursor-pointer` compiles to nothing — write the rule in your own CSS', + 'tailwind/utilities.blade.php:2 Tailwind interactivity utility `select-none` compiles to nothing — write the rule in your own CSS', + 'tailwind/utilities.blade.php:3 Tailwind media utility `aspect-video` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS', + 'tailwind/utilities.blade.php:3 Tailwind media utility `object-cover` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS', + 'tailwind/utilities.blade.php:3 Tailwind text utility `font-mono` compiles to nothing — write the rule in your own CSS', + "tailwind/utilities.blade.php:3 Tailwind's `font-sans` compiles to nothing — the foundation already sets the brand typeface; any other `font-family` is your own CSS", + "tailwind/utilities.blade.php:3 Tailwind's `underline` compiles to nothing — `md-link` draws a link (interaction.css); any other decoration is your own CSS", + 'tailwind/utilities.blade.php:3 Tailwind text utility `uppercase` compiles to nothing — write the rule in your own CSS', + "tailwind/utilities.blade.php:3 Tailwind's `text-end` compiles to nothing — use `md-text-end` (text.css)", + 'tailwind/utilities.blade.php:3 Tailwind text utility `list-disc` compiles to nothing — write the rule in your own CSS', + 'tailwind/utilities.blade.php:3 Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS', + 'tailwind/utilities.blade.php:3 Tailwind background utility `bg-clip-text` compiles to nothing — write the rule in your own CSS', + ]); +}); + +it('reads class lists in Alpine, Livewire and PHP bindings, but not a string compared in a condition', function () { + $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/bindings.blade.php'))->violations()); + + expect($violations)->toBe([ + "tailwind/bindings.blade.php:1 Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`", + "tailwind/bindings.blade.php:1 Tailwind opacity utility `opacity-50` compiles to nothing — write `opacity` in your own CSS (M3's disabled content is 38 %)", + 'tailwind/bindings.blade.php:2 Tailwind motion utility `transition` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`', + 'tailwind/bindings.blade.php:2 Tailwind effect utility `scale-95` compiles to nothing — write the rule in your own CSS', + 'tailwind/bindings.blade.php:3 Tailwind spacing utility `gap-2` compiles to nothing — use `gap="space100"` (8px) on ``, ``, `` or ``', + "tailwind/bindings.blade.php:4 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS", + 'tailwind/bindings.blade.php:6 Tailwind layout utility `flex` compiles to nothing — use `` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `` for a column', + 'tailwind/bindings.blade.php:2 value outside the M3 scale `ease-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`', + ]); +}); + it('bans any further pattern', function () { $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views')) ->forbid('/\bfocus-ring\b/', 'a retired utility') diff --git a/tests/Fixtures/design-guard/scale/corners.blade.php b/tests/Fixtures/design-guard/scale/corners.blade.php index 23676e99..6d872ebb 100644 --- a/tests/Fixtures/design-guard/scale/corners.blade.php +++ b/tests/Fixtures/design-guard/scale/corners.blade.php @@ -1,5 +1,5 @@
- M3's own are fine + M3's own are fine
diff --git a/tests/Fixtures/design-guard/scale/elevation.blade.php b/tests/Fixtures/design-guard/scale/elevation.blade.php index 475886f1..172fe44e 100644 --- a/tests/Fixtures/design-guard/scale/elevation.blade.php +++ b/tests/Fixtures/design-guard/scale/elevation.blade.php @@ -1,4 +1,4 @@
- M3's own are fine + M3's own are fine
diff --git a/tests/Fixtures/design-guard/scale/motion.blade.php b/tests/Fixtures/design-guard/scale/motion.blade.php index 1b93c957..4d1df450 100644 --- a/tests/Fixtures/design-guard/scale/motion.blade.php +++ b/tests/Fixtures/design-guard/scale/motion.blade.php @@ -1,4 +1,4 @@
- M3's own are fine + M3's own are fine
diff --git a/tests/Fixtures/design-guard/scale/type.blade.php b/tests/Fixtures/design-guard/scale/type.blade.php index f6144a9e..fe9cb9da 100644 --- a/tests/Fixtures/design-guard/scale/type.blade.php +++ b/tests/Fixtures/design-guard/scale/type.blade.php @@ -3,5 +3,5 @@ - M3's own are fine + M3's own are fine diff --git a/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php b/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php index ee97d015..45d39ee4 100644 --- a/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php +++ b/tests/Fixtures/design-guard/tailwind/arbitrary.blade.php @@ -1,3 +1,4 @@
+
diff --git a/tests/Fixtures/design-guard/tailwind/bindings.blade.php b/tests/Fixtures/design-guard/tailwind/bindings.blade.php new file mode 100644 index 00000000..57557f0f --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/bindings.blade.php @@ -0,0 +1,8 @@ +
+
    $status === 'hidden', 'md-ink' => isActive('block')]) + {{ $attributes->merge(['class' => 'mt-2']) }}> +
  • Row
  • +
+
diff --git a/tests/Fixtures/design-guard/tailwind/one-x.blade.php b/tests/Fixtures/design-guard/tailwind/one-x.blade.php new file mode 100644 index 00000000..8a0f62d7 --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/one-x.blade.php @@ -0,0 +1,3 @@ + diff --git a/tests/Fixtures/design-guard/tailwind/utilities.blade.php b/tests/Fixtures/design-guard/tailwind/utilities.blade.php new file mode 100644 index 00000000..8ba2ad1d --- /dev/null +++ b/tests/Fixtures/design-guard/tailwind/utilities.blade.php @@ -0,0 +1,4 @@ +
+ + +