Document the design guard as it now stands

Plan step 41 review: README and the development skill describe the API
that exists after the review, briefly — what scan() reads, the family
table with the M3 spacing step and 1.x utilities, missingStylesheets()
reading imports only, forbidColours() on the 2.0.0 vocabulary — and
UPGRADE.md lists the removed checks and methods and the retargeted
forbidColours() (the user, 2026-09-15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 08:51:32 +02:00
co-authored by Claude Opus 5
parent bbd0192790
commit 87004a9035
3 changed files with 36 additions and 25 deletions
+3 -3
View File
@@ -143,16 +143,16 @@ While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/materia
use NoNameWeb\LivewireMaterial\Testing\DesignGuard; use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
it('uses only what compiles', function () { it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()]) expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
->missingStylesheets(resource_path('css/app.css')) ->missingStylesheets(resource_path('css/app.css'))
->forbidColours(['tertiary']) ->forbidColours(['tertiary'])
->violations())->toBe([]); ->violations())->toBe([]);
}); });
``` ```
The guard fails on Tailwind palette colours, unknown Material Symbol names and Blade directives written inside component tags. `forbidColours([...])` names roles the application leaves out, and fails wherever one is still written: its `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. It also fails on every Tailwind utility or variant still sitting in a view, PHP or JS file — a breakpoint prefix, a radius, shadow, type size/weight/leading/tracking, easing or duration, a flex/grid/spacing/sizing/display utility, a text-layout utility, an M3 role utility (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary `[…]` value — none of which compile any more, since the application carries no Tailwind: every line names its 2.0.0 replacement, a layout component and prop (`flex gap-4``<x-row gap="space200">`), an `md-*` class (`truncate``md-truncate`), or a token for the application's own CSS (`rounded-lg``var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt, and so is every `md-*` class. The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): a layout component and prop (`flex gap-4``<x-row gap="space200">`), an `md-*` class (`truncate``md-truncate`), or a token for the application's own CSS (`rounded-lg``var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags.
`missingStylesheets($cssEntry)` checks the CSS entry's `@import` graph (followed through every package file's own imports) against the package tags a view actually renders — unprefixed, under the configured prefix, or `<x-livewire-material::…>` and `->links()`; each missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths), which reports a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or off-scale media query with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine, and the generated `material-scheme.css` is skipped. `missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or `<x-livewire-material::…>`) and `->links()`, and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals. `forbidColours([...])` names roles the application leaves out, and fails wherever one (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds any pattern of your own, line by line.
## AI agents ## AI agents
+12 -2
View File
@@ -156,8 +156,18 @@ the contrast toggle (`<x-theme-toggle mode="contrast">`) and the Standard motion
### 8. Tests and guards ### 8. Tests and guards
Add `DesignGuard`'s new checks to the application's design test (`forbidOpacityInk()` and `DesignGuard` changes with the stack:
`forbidAbsolutes()` are opt-in). Browser tests that assert widths switch at 640/1024/1280 now
- **Removed:** the maryUI tag and daisyUI class checks, `forbidAbsolutes()` and `forbidOpacityInk()`.
Every Tailwind utility now compiles to nothing and is reported with its replacement, so
`bg-white` and `text-on-surface/60` still are.
- **Retargeted:** `forbidColours([...])` keeps its signature and reports a left-out role where 2.0.0
writes one: `var(--md-sys-color-…)` in CSS or an inline `style`, an `md-ink-*` class, a
component's `color`/`tone` prop.
- **New:** `missingStylesheets($cssEntry)` names each `@import` the views need; a `.css` file passed
to `scan()` is checked for literal values and off-scale media queries.
Browser tests that assert widths switch at 640/1024/1280 now
switch at 600/840/1200; tests that read `role="alert"` on an alert, `aria-pressed` on the time switch at 600/840/1200; tests that read `role="alert"` on an alert, `aria-pressed` on the time
picker's period buttons or `data-theme-option` need the new hooks above. picker's period buttons or `data-theme-option` need the new hooks above.
@@ -1110,41 +1110,42 @@ Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor)
use NoNameWeb\LivewireMaterial\Testing\DesignGuard; use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
it('uses only what compiles', function () { it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()]) expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
->missingStylesheets(resource_path('css/app.css')) ->missingStylesheets(resource_path('css/app.css'))
->forbidColours(['tertiary']) // roles this application's rules leave out ->forbidColours(['tertiary']) // roles this application's rules leave out
->violations())->toBe([]); ->violations())->toBe([]);
}); });
``` ```
It fails on Tailwind palette colours, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each. `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. It reads every path it is given and fails with `path:line` on unknown symbol names, Blade directives written inside a component tag (where they do not compile), and every Tailwind utility or variant — none compiles in a Tailwind-free application — or 1.x utility, each with its 2.0.0 replacement. A class the application's own stylesheets declare is exempt, and so is every `md-*` class:
It also fails on every Tailwind utility or variant, which compile to nothing in a Tailwind-free application, 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:
| Written | Use | | Written | Use |
| --- | --- | | --- | --- |
| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` (and `max-`), `hover:`, `dark:` … | a layout component's `hide-below`/`hide-from`/`stack-below` prop, `:has()`/`aria-*`/`data-md-*`, or a plain `@media`/state rule | | `sm:`, `md:`, `lg:`, `xl:`, `2xl:` (and `max-`) | `@media (width >= 600px)` …, or a layout component's `hide-below`/`hide-from`/`stack-below` prop |
| `flex`, `grid`, `gap-4`, `grid-cols-*`, `items-center` … | `<x-row>`, `<x-grid>` or `<x-stack>` and their `gap`/`align`/`justify`/`columns` props | | `hover:`, `dark:`, `group-hover:`, `rtl:` … | `md-state-layer`/`md-focus-ring`, `[data-theme='dark']`, `:has()`, `:is([dir='rtl'], [dir='rtl'] *)` in your own CSS |
| `p-4`, `mt-2`, `space-y-4` | a layout component's `gap`/`padding` prop, or `var(--md-sys-measurement-space*)` | | `flex`, `flex-col`, `flex-wrap`, `items-center`, `justify-between` | `<x-row>`, `<x-stack>`, `<x-row wrap>`, `align="center"`, `justify="between"` |
| `w-full`, `h-screen`, `min-w-0` | a literal length in your own CSS — M3 keeps no size scale | | `grid`, `grid-cols-3` | `<x-grid :columns="3">` (or a per-breakpoint map), `<x-feed>` for cards |
| `hidden` | a layout component's `hide-below`/`hide-from` prop, or `[hidden]` | | `gap-4`, `space-y-4`, `p-4`, `mt-2` | the M3 step at 4px per unit: `gap="space200"` (16px), `<x-stack gap="space200">`, `<x-surface padding="space200">`, `var(--md-sys-measurement-space100)` |
| `block`, `inline-block`, `invisible` | a plain `display` rule in your own CSS | | `w-full`, `max-w-5xl`, `size-6` | `<x-pane width>`, `<x-icon size>`, or a length in your own CSS — M3 keeps no size scale |
| `hidden` | a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute, or `x-show` |
| `block`, `relative`, `inset-0`, `z-10`, `overflow-hidden`, `shrink-0`, `border`, `opacity-50`, `cursor-pointer` … | the rule in your own CSS |
| `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` | | `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` |
| `type-body-md`, `focus-ring`, `state-layer`, `link` (1.x) | `md-type-body-md`, `md-focus-ring`, `md-state-layer`, `md-link` |
| `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … | | `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … |
| `border-outline-variant` | `<x-divider>` or `<x-surface outlined>` | | `border-outline-variant`, `border-divider` | `<x-divider>` or `<x-surface outlined>` |
| `bg-surface-container` | `<x-surface level="surface-container">` | | `bg-surface-container` | `<x-surface level="surface-container">` |
| `bg-primary`, `border-error` … | `var(--md-sys-color-*)` | | `bg-primary`, `border-error`, `text-red-500` … | `var(--md-sys-color-*)` |
| `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black | | `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black |
| `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay | | `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay |
| `rounded-lg`, `rounded-t-2xl`, `rounded-full` | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` | | `rounded-lg`, `rounded-corner-lg` (1.x) | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` |
| `shadow-sm`, `shadow-md``shadow-2xl` | `var(--md-sys-elevation-1)` `var(--md-sys-elevation-5)` | | `shadow-md`, `shadow-elevation-2` (1.x) | `var(--md-sys-elevation-2)` |
| `text-sm`, `leading-6`, `tracking-wide` | one of the `md-type-*` classes, which set size, line height and tracking together | | `text-sm`, `leading-6`, `tracking-wide`, `font-medium` | one of the `md-type-*`/`md-type-emphasized-*` classes |
| `font-medium`, `font-bold` | one of the `md-type-emphasized-*` classes | | `ease-in-out`, `ease-spatial-fast` (1.x), `duration-300` | `var(--md-sys-motion-spatial-fast)` with its `-duration`, in your own `transition` |
| `ease-in-out`, `ease-linear` | `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration` | | `bg-[#1d7afc]`, `w-[320px]`, `[--x:1]` | an M3 role, or the value in your own CSS |
| `duration-300` | `var(--md-sys-motion-…-duration)`, paired with its easing |
| `bg-[#1d7afc]`, `text-[rgb(…)]`, `w-[320px]` | an M3 role, or the literal value in your own CSS |
`missingStylesheets($cssEntry)` follows the entry's `@import` graph through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too) and checks it against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; a missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead — the application's component wins in Blade. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths, `material-scheme.css` excluded): a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or a media query off M3's 600/840/1200/1600px scale, each with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine.
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own.
## Conventions ## Conventions