Take Tailwind out of the package, and its detection out of the guard
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s

Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 21:07:39 +02:00
co-authored by Claude Opus 5
parent 247c596c3a
commit fb7007c976
88 changed files with 649 additions and 1521 deletions
+5
View File
@@ -52,6 +52,10 @@ Spring motion constants (resources/css/tokens/motion.css)
From androidx Compose Material 3, tokens/ExpressiveMotionTokens.kt.
Copyright The Android Open Source Project. Apache License 2.0.
CSS reset (resources/css/foundation/reset.css)
Derived rule for rule from Tailwind CSS 4's preflight, after modern-normalize.
Copyright Tailwind Labs. MIT License.
Material Design 3 documentation (resources/boost/guidelines/material-3.blade.php,
resources/boost/skills/material-3-design/SKILL.md, docs/reference/m3)
Rules, tables and wording condensed from https://m3.material.io (Foundations, Styles,
@@ -62,3 +66,4 @@ resources/boost/skills/material-3-design/SKILL.md, docs/reference/m3)
Copyright The Android Open Source Project (Apache License 2.0).
A copy of the Apache License 2.0 is available at https://www.apache.org/licenses/LICENSE-2.0.
A copy of the MIT License is available at https://opensource.org/licenses/MIT.
+4 -4
View File
@@ -27,7 +27,7 @@ composer require nonameweb/livewire-material
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
The package's stylesheets are plain CSS, no build step of its own and no Tailwind anywhere in the stack. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.
The package's stylesheets are plain CSS, no build step of its own. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.
```css
/* resources/css/app.css */
@@ -94,7 +94,7 @@ php artisan vendor:publish --tag=livewire-material-config
- `motion.scheme` — M3's motion scheme: `expressive` (default) or `standard`, the restrained springs, written to `<html data-motion>`.
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`); the provider also sets `livewire.pagination_theme` to `material` whenever it still reads as Livewire's own default (`tailwind`), unless an application's own theme, or a component's `$paginationTheme` property or `paginationView()` method, already chose one.
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
- `node` — the Node binary for `material:scheme`.
@@ -150,9 +150,9 @@ it('uses only what compiles', function () {
});
```
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, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — each with its 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. 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. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt classes.
The guard reads every path it is given and fails, with `path:line`, on a class written in a view or PHP file that no stylesheet declares — not the application's own CSS, not the package's `md-*` — whatever it came from: a utility of a framework that isn't in the stack, a typo, or a class whose rule was deleted. Without a `.css` file among the scanned paths or a `missingStylesheets()` entry to supply the application's import graph, it has no declared class to compare against and reports every one of them. 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. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read by the class check, and a mail theme stylesheet there is neither checked for literals nor a source of declared classes.
`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::…>`), `->links()` and the rows they write by hand (`data-md-list-row` on anything but `<x-card>` needs `components/list-item.css`), 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.
`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::…>`), `->links()` and the rows they write by hand (`data-md-list-row` on anything but `<x-card>` needs `components/list-item.css`), 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 — as long as `missingStylesheets()` is given an entry, its import graph still supplies the declared classes family (i) needs. `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
+19 -9
View File
@@ -1,6 +1,6 @@
# Upgrading
## From 2.1.0 to 2.1.1
## From 2.1.0 to 2.2.0
- **Run `php artisan view:clear` after upgrading.** The components are no longer registered under a
view namespace named after a hash of their folder, which only views compiled before 1.0.1 still
@@ -28,14 +28,6 @@
under that path is no longer checked for literal values, and its classes no longer exempt the
same names in the application's other views. A filter an application wrote around such a
finding can go.
- **`DesignGuard`** reports a colour utility on a colour the application's own Tailwind theme
named (`bg-brand`, `text-sport-run`, `from-brand`, `border-l-zone-4`, `bg-brand/8`) in a class
list, with `var(--…)` of the application's own as its replacement, unless the application's
CSS declares the class. None of them has compiled since 2.0.0, and they were the leftovers its
count never showed. Tailwind's other utilities on the same prefixes are named by their own
family now too: `border-collapse`, `border-spacing-*`, `from-10%`, `via-none`, `fill-none`,
`stroke-*`, `text-shadow-*`, `text-inherit`, and Tailwind 4.2's `mauve`, `olive`, `mist` and
`taupe` palettes. A test that expects no findings may fail on them.
- **`DesignGuard`** counts a row written by hand. `data-md-list-row` on an `<li>`, a `<div>`, a
`<tr>` or `<x-row>` takes its hover, focus and press state layer and its selected fill from
`components/list-item.css`, but `missingStylesheets()` and `unusedStylesheets()` read only tags
@@ -121,6 +113,24 @@
itself from outside; a Tab onto the control, including the one that wraps round to it, and focus
moved within the layer still show them. An `autofocus` an application put on another control to
keep the focus off the close button can go.
- **`DesignGuard`** reports any class a view or PHP file writes that no stylesheet declares —
not the package's `md-*`, not the application's own CSS — instead of naming Tailwind's
utilities, variants and theme colours with their 2.0.0 replacement. It now catches a dead class
from any source: a typo, a utility of a framework that isn't in the stack, a class whose rule
was deleted — not only Tailwind's — but a class assembled in a PHP string (`'text-'.$tone`) is
still not seen. A test that asserted on the old Tailwind-shaped message needs the new one
instead.
- **The pagination views are `material.blade.php` and `simple-material.blade.php`**, no longer named
after Tailwind. The provider sets `Paginator::defaultView('pagination::material')` and
`defaultSimpleView('pagination::simple-material')`, and switches `livewire.pagination_theme` to
`material` whenever it still reads as Livewire's own default (`tailwind`, including when the key
is missing), so an application can no longer forget the config and silently render Livewire's own
Tailwind view. An application that already chose another theme, or a component with its own
`$paginationTheme` property or `paginationView()` method, is untouched — either still wins.
- **`foundation.css` no longer explains how to order layers beside Tailwind.** No consumer builds
Tailwind any more, so the paragraph about opening both entries with
`@layer properties, theme, base, material, components, utilities;` is gone; the layer statement
and the order it declares are otherwise unchanged.
## From 2.0.0 to 2.1.0
+5 -4
View File
@@ -107,10 +107,11 @@ return [
| Pagination
|--------------------------------------------------------------------------
|
| Draw Laravel's and Livewire's paginators in M3: the package's views are
| put in front of `pagination::tailwind` and `livewire::tailwind` (and
| their simple versions). An application's own published pagination views
| still win.
| Draw Laravel's and Livewire's paginators in M3: the package's views
| become `Paginator::defaultView()`/`defaultSimpleView()`, and
| `livewire.pagination_theme` is taken over while it is still Livewire's
| own default. An application's own published pagination views still
| win, and so does a theme it chose deliberately.
|
*/
+1 -1
View File
@@ -1,7 +1,7 @@
@verbatim
## Livewire Material
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. It replaces UI kits such as maryUI, daisyUI and Flux, and Tailwind CSS, in this application.
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. No utility classes Tailwind, daisyUI or any other belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it.
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
- The CSS entry imports `foundation.css` first, then the stylesheet of each component the views render (or `all.css` for all of them). A component whose stylesheet is not imported renders unstyled; `DesignGuard::missingStylesheets()` names each missing `@import`.
@@ -3,7 +3,7 @@
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one — activate it before designing a screen.
The library is plain CSS on M3's tokens, and there are no utility classes: a Tailwind class, one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden) or a daisyUI class compiles to nothing, and so does a maryUI tag. A view is written three ways:
The library is plain CSS on M3's tokens. No utility classes Tailwind, daisyUI or any other belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it. A view is written three ways:
- Components and their props: `<x-button variant="filled">`, and the layout components `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` and `<x-pane>`, whose `gap` and `padding` take a spacing token (`space200`) and whose `hide-below`, `hide-from` and `stack-below` take a window size class.
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
@@ -90,7 +90,7 @@ Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
## Tokens
2.0.0's vocabulary is plain CSS. There are no utility classes: a Tailwind class, or one of 1.x's own colour, type, shape, elevation, motion or breakpoint utilities, compiles to nothing, and the design guard names the replacement for each (see Testing the design). `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token, and the classes below. An application's views reach for four things, in this order:
2.0.0's vocabulary is plain CSS. There are no utility classes: one of 1.x's own colour, type, shape, elevation, motion or breakpoint utilities, or any other class no stylesheet declares, compiles to nothing, and the design guard fails on it (see Testing the design). `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale, every `--md-sys-*` token, and the classes below. An application's views reach for four things, in this order:
- **A component's own props** — `color`, `variant`, `size`, `dense` — before anything else.
- **Layout components** for arrangement and spacing: `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` inside `<x-pane>`s (see Layout), with `gap` and `padding` as spacing tokens (`space25``space900`, 272px) and `hide-below`, `hide-from` and `stack-below` for the window size classes. Nothing arranges by class.
@@ -1164,7 +1164,7 @@ A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`;
{{ $shares->links() }}
```
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win.
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win. The provider also sets `livewire.pagination_theme` to `material` whenever it still reads as Livewire's own default (`tailwind`); an application's own theme, or a component's `$paginationTheme` property or `paginationView()` method, still wins.
## Testing the design
@@ -1180,13 +1180,13 @@ it('uses only what compiles', function () {
});
```
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 — each match printed beside its own 2.0.0 replacement (a layout component and prop, an `md-*` class, a `var(--md-sys-…)` token, or your own CSS), so run the check and read its output rather than a table here. A class the application's own stylesheets declare is exempt, and so is every `md-*` class.
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 a class no stylesheet declares — not your own CSS, not the package's `md-*` — whatever it came from: a leftover utility, a typo, a class whose rule was deleted. Without a `.css` file among the scanned paths or a `missingStylesheets()` entry to supply your import graph, it has no declared class to compare against and reports every one of them.
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.
Markdown mail components — whatever lies under `config('mail.markdown.paths')`, `resources/views/vendor/mail` by default — are drawn by the mail theme, not by your CSS entry, and its classes (`table`, `button`, `panel`, `break-all`) share Tailwind's names. So the guard skips the class checks there (it still reads their icon names, the directives in their component tags and your own bans), and a mail theme stylesheet under that path, which has to write literal values, is neither checked for literals nor a source of exempt classes.
Markdown mail components — whatever lies under `config('mail.markdown.paths')`, `resources/views/vendor/mail` by default — are drawn by the mail theme, not by your CSS entry. So the guard skips the class check there (it still reads their icon names, the directives in their component tags and your own bans), and a mail theme stylesheet under that path, which has to write literal values, is neither checked for literals nor a source of declared classes.
`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::…>`), `->links()`, and a row a view of yours writes by hand (`data-md-list-row` on anything but `<x-card>`, which needs `components/list-item.css`; the package's own views are not read for it); 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. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs — no tag, `->links()` or row written by hand — even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `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.
`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; a package name or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`), `->links()`, and a row a view of yours writes by hand (`data-md-list-row` on anything but `<x-card>`, which needs `components/list-item.css`; the package's own views are not read for it); 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, as long as this entry is what `missingStylesheets()` reads — its import graph is still what supplies the declared classes above. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs — no tag, `->links()` or row written by hand — even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `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
+3 -3
View File
@@ -1,9 +1,9 @@
/*
* Livewire Material for an application that wants everything: the foundation, every layout
* stylesheet and every component stylesheet, each imported once. The blocks below are the same
* groups `components.css` and `layout.css` used before this file replaced both of them (plan step
* 37) one block per group, so a stylesheet still lands in a predictable place plus the
* Layout block those two files didn't share.
* groups `components.css` and `layout.css` used before this file replaced both of them one
* block per group, so a stylesheet still lands in a predictable place plus the Layout block
* those two files didn't share.
*
* This is not the recommended way to bring the package's CSS in: importing `all.css` pulls in
* every component's rules whether an application's views render them or not. The recommended path
+4 -4
View File
@@ -20,10 +20,10 @@
* The page carries this file itself: `src/Support/ErrorPage.php` inlines it, with its imports, into
* every error page (`Stylesheets::bundle()`) beside the application's Vite tags when there is a
* build (`layoutStyles()`), inside the whole fallback stylesheet when there is none
* (`fallbackStyles()`, plan step 40) so an application imports nothing for its error pages
* (plan step 46). The body is still matched only when it holds the layout
* (`body:has(> [data-md-error-page])`): an application that imports `all.css` has this file in
* its bundle beside every other page, where a bare `body` rule would restyle them all.
* (`fallbackStyles()`) so an application imports nothing for its error pages. The body is still
* matched only when it holds the layout (`body:has(> [data-md-error-page])`): an application that
* imports `all.css` has this file in its bundle beside every other page, where a bare `body` rule
* would restyle them all.
*
* The page's font is the foundation's, `--md-ref-typeface-brand` on `html` (foundation/base.css),
* as the old `font-sans` was; only the fallback, which has no `@font-face`, uses a system stack.
+5 -8
View File
@@ -78,14 +78,11 @@
* A bar that has slid off the bottom of the window is no longer there to clear, so the offset
* everything pinned to the bottom reads drops to the bottom safe area and whatever the
* application has docked on the bar a `fab` button, the snackbar and the page's own bottom
* padding all follow it down and come back up with it. Layered now, unlike before this
* rewrite: <x-scaffold> used to publish --material-bottom-bar with a Tailwind utility, which
* no rule in any layer could outrank regardless of specificity; now scaffold.css publishes it
* itself in `material.layout` (layout/scaffold.css), a layer this file's own
* `material.components` always outranks by declaration order alone the same reason
* toolbar.css's and fab.css's overrides only have to beat another `material.components` rule,
* not chase a Tailwind utility out of the cascade. Keyed on `data-md-scaffold`, the hook the
* scaffold renders on its root, not `data-app-shell`, which that rewrite also retired.
* padding all follow it down and come back up with it. `<x-scaffold>` publishes
* `--material-bottom-bar` itself, in `material.layout` (layout/scaffold.css), a layer this
* file's own `material.components` always outranks by declaration order alone the same
* reason toolbar.css's and fab.css's overrides only have to beat another `material.components`
* rule. Keyed on `data-md-scaffold`, the hook the scaffold renders on its root.
*/
[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) {
--material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));
+4 -3
View File
@@ -1,7 +1,8 @@
/*
* The paginators, Laravel's and Livewire's, drawn in M3 (resources/views/pagination/**): the package
* puts its views in front of the frameworks' `tailwind` and `simple-tailwind` ones, whose names they
* keep.
* The paginators, Laravel's and Livewire's, drawn in M3 (resources/views/pagination/**): named
* `material`/`simple-material`, put in front of the frameworks' own namespaces and set as
* Laravel's default view (`Paginator::defaultView()`/`defaultSimpleView()`) and, where Livewire
* still reads its own default, as its `pagination_theme`.
*
* The numbered paginator is a row: on its start, what the page holds in body-small on-surface-variant
* "2130 of 95", or "Page 3 of 10" below `medium` (600px), where the page numbers give way and only
-4
View File
@@ -24,10 +24,6 @@
* therefore outranks every package rule, whatever the selector an application's broad selector
* (`button { }`) restyles the components too. foundation/hidden.css keeps its two `!important`
* rules outside the layers, so `hidden` and `x-cloak` hide an element whatever sets its display.
*
* An application still building Tailwind keeps it in a separate entry and opens both entries with
* `@layer properties, theme, base, material, components, utilities;`, so the `material` layers sit
* above Tailwind's preflight and below its utilities.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+1 -1
View File
@@ -11,7 +11,7 @@
* Text is smoothed in grayscale where a platform offers the choice (macOS browsers): Google Sans
* Flex is drawn for it, and subpixel smoothing thickens its strokes on light text over a dark
* surface. Every surface the package draws the showcase, the error pages is set this way,
* and so is an application, which drops Tailwind's `antialiased` class with nothing to replace.
* and so is an application, with no class of its own needed.
*
* In `material.base`, above the reset and below every component and text class.
*/
+4 -4
View File
@@ -2,10 +2,10 @@
* The reset: what a browser draws before any stylesheet, taken back to a clean start.
*
* Rule for rule the reset the components were built on, Tailwind CSS 4's preflight (MIT,
* © Tailwind Labs, after modern-normalize), so a component looks the same whether an application
* still builds Tailwind or not: every box `border-box` with a `0 solid` border and no margin or
* padding, headings and lists unstyled, form controls inheriting font, colour and tracking with no
* background or radius, media as blocks that never overflow their parent. Three differences:
* © Tailwind Labs, after modern-normalize): every box `border-box` with a `0 solid` border and no
* margin or padding, headings and lists unstyled, form controls inheriting font, colour and
* tracking with no background or radius, media as blocks that never overflow their parent. Three
* differences:
*
* - no typeface here: foundation/base.css sets `--md-ref-typeface-brand` on <html>;
* - a placeholder is `on-surface-variant`, the colour of M3's text field placeholder (androidx
+1 -1
View File
@@ -21,7 +21,7 @@
* way it goes, so it mirrors in a right-to-left document (§ Layout Bidirectionality / RTL).
*
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css. Imports app-bar.css and
* button.css for the pane's own top app bar and its back button (plan step 36 carry-over).
* button.css for the pane's own top app bar and its back button.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+7 -10
View File
@@ -2,9 +2,7 @@
* <x-scaffold>: M3's scaffold bars, then rails, then panes (docs/reference/m3/foundations.md
* § Layout Scaffold) as a column: an optional `banner` full width, a row below it holding the
* adaptive navigation rail and the content region, the bottom navigation bar on a compact window,
* and the snackbar host. Plan step 36 (navigation group) moves in everything the view drew with
* Tailwind; `data-md-scaffold-fab` and the content region's `--md-layout-margin` are step 35's,
* unchanged below.
* and the snackbar host.
*
* `--material-margin` is M3's window margin, which the content region pads itself with and an
* application's own rules can read: 16px below `medium`, 24px from it
@@ -23,11 +21,11 @@
* `[data-md-scaffold-content]` the rail's sibling — is always a flex column, so the page's own
* bar, the FAB wrapper and `<main>` stack regardless of the row's own display.
*
* `[data-md-scaffold-actions]`'s column layout is `rail-collapsed:flex-col`, the one Tailwind
* variant scaffold.blade.php still used (`resources/css/tailwind.css`, removed with this rule): the
* actions row is always inside a rail, never the rail element itself, so it reads
* `--md-navigation-rail-value` with `@container style()` like any other application content does
* (`resources/css/components/navigation-rail.css`), rather than copying the rail's own conditions.
* `[data-md-scaffold-actions]`'s column layout comes from a container style query on
* `--md-navigation-rail-value: collapsed`: the actions row is always inside a rail, never the rail
* element itself, so it reads that property with `@container style()` like any other application
* content does (`resources/css/components/navigation-rail.css`), rather than copying the rail's
* own conditions.
*
* `[data-md-scaffold-bar]` pins the navigation bar to the window's bottom edge below `medium`, at
* z-index 30: over the page, under the rail's scrim (40) and the snackbar (50, toast.css). While a
@@ -38,8 +36,7 @@
* (toast.css), a placed toolbar (toolbar.css) and the content region's bottom padding. Without a
* bar, or from `medium`, the scaffold publishes nothing and each reader falls back to 0px. A bar that has slid
* away on scroll drops its own height from the variable (navigation-bar.css); that rule wins
* because `material.components` comes after this file's `material.layout`, where before this
* rewrite the variable was a Tailwind utility and the override had to sit outside every layer.
* because `material.components` comes after this file's `material.layout` in the cascade.
*
* The content region's `<main>` is matched as the content wrapper's own child, so a `<main>` an
* application nests inside the page takes none of the region's padding or clipping.
+2 -2
View File
@@ -1,7 +1,7 @@
/*
* The showcase's own chrome: what its pages draw beyond the layout components, the `md-*` text
* classes and the shared interaction classes (plan step 38) the rail's wordmark, the search box
* and its results, a keyboard key in running text, an example's card and its code sample, and the
* classes and the shared interaction classes the rail's wordmark, the search box and its
* results, a keyboard key in running text, an example's card and its code sample, and the
* foundation pages' specimens: a colour swatch, a type-scale row, a corner swatch, an elevation
* tile, a motion track and its dot, an icon search result and the Layout page's breakpoint cards.
*
@@ -1,8 +1,9 @@
{{-- Laravel's paginator (`$items->links()` outside Livewire), drawn in M3 as Livewire's is
(resources/views/pagination/livewire/tailwind.blade.php): links instead of actions, the current
page in secondary-container, "Page 2 of 7" below 600px. The package puts this in front of
`pagination::tailwind` (config `livewire-material.pagination`); the name is Laravel's, the drawing
resources/css/components/pagination.css. --}}
(resources/views/pagination/livewire/material.blade.php): links instead of actions, the
current page in secondary-container, "Page 2 of 7" below 600px. The name is `material`; the
provider replaces Laravel's own default with
`Paginator::defaultView('pagination::material')` (config `livewire-material.pagination`). The
drawing is resources/css/components/pagination.css. --}}
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-md-pagination>
@@ -1,5 +1,6 @@
{{-- Laravel's simple and cursor paginator, drawn in M3: previous and next as outlined buttons
(the package puts this in front of `pagination::simple-tailwind`). --}}
{{-- Laravel's simple and cursor paginator, drawn in M3: previous and next as outlined buttons.
The name is `simple-material`; the provider replaces Laravel's own default with
`Paginator::defaultSimpleView('pagination::simple-material')`. --}}
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-md-pagination>
@@ -1,7 +1,7 @@
{{-- Livewire's paginator, drawn in M3. The package puts this in front of Livewire's own
`livewire::tailwind` (config `livewire-material.pagination`), whose gray and blue classes do
not exist in an M3 theme. The file keeps Livewire's view name; nothing in it is Tailwind the
drawing is resources/css/components/pagination.css.
{{-- Livewire's paginator, drawn in M3. The name is `material`; Livewire resolves
`livewire::` . config('livewire.pagination_theme') to find it, and the provider sets that
config to `material` (config `livewire-material.pagination`) while it still reads as
Livewire's own default. The drawing is resources/css/components/pagination.css.
The wiring is Livewire's, unchanged: `previousPage`, `nextPage` and `gotoPage` with the page
name, and the scroll back to the top of whatever `scrollTo` names. A page number is drawn as a
@@ -1,5 +1,7 @@
{{-- Livewire's simple and cursor paginator, drawn in M3: previous and next as outlined buttons
(the package puts this in front of `livewire::simple-tailwind`). --}}
{{-- Livewire's simple and cursor paginator, drawn in M3: previous and next as outlined buttons.
The name is `simple-material`; Livewire resolves `livewire::simple-` . config
('livewire.pagination_theme') to find it, which the provider sets to `material` while it
still reads as Livewire's own default. --}}
@php
if (! isset($scrollTo)) {
@@ -1,6 +1,6 @@
{{-- The live breakpoint readout: this window's width, and the M3 breakpoint it falls in, read
from the same numbers resources/js/breakpoints.js compares against. Shared by the Layout
section's Overview and its three canonical layout pages (plan step 38). --}}
section's Overview and its three canonical layout pages. --}}
<x-livewire-material::surface
level="surface-container-low"
@@ -1,6 +1,6 @@
{{-- The feed canonical layout on a page of its own (plan step 38): the same working demo the
Layout section's Overview linked from its own navigation, built on the real <x-feed>, with
the live breakpoint readout the Overview page has. --}}
{{-- The feed canonical layout on a page of its own: the same working demo the Layout section's
Overview linked from its own navigation, built on the real <x-feed>, with the live
breakpoint readout the Overview page has. --}}
@extends('livewire-material::showcase.layout')
@@ -1,6 +1,6 @@
{{-- The list-detail canonical layout on a page of its own (plan step 38): the same working demo
the Layout section's Overview linked from its own navigation, built on the real
<x-list-detail>, with the live breakpoint readout the Overview page has. --}}
{{-- The list-detail canonical layout on a page of its own: the same working demo the Layout
section's Overview linked from its own navigation, built on the real <x-list-detail>, with
the live breakpoint readout the Overview page has. --}}
@extends('livewire-material::showcase.layout')
@@ -1,5 +1,5 @@
{{-- The supporting-pane canonical layout on a page of its own (plan step 38): the same working
demo the Layout section's Overview linked from its own navigation, built on the real
{{-- The supporting-pane canonical layout on a page of its own: the same working demo the
Layout section's Overview linked from its own navigation, built on the real
<x-supporting-pane>, with the live breakpoint readout the Overview page has. --}}
@extends('livewire-material::showcase.layout')
@@ -3,8 +3,8 @@
describing them.
Then the layout components, and the way on to the three canonical layouts list-detail,
supporting pane, feed each on a page of its own (plan step 38), reached through the
section navigation below. --}}
supporting pane, feed each on a page of its own, reached through the section navigation
below. --}}
@php
$examples = [
+2 -2
View File
@@ -33,8 +33,8 @@ Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
Route::get('search.json', [ShowcaseController::class, 'search'])->name('search');
// The Layout section's three canonical layouts (plan step 38), each a page of its own rather
// than an anchor on the Layout section's Overview (`{section}` below).
// The Layout section's three canonical layouts, each a page of its own rather than an
// anchor on the Layout section's Overview (`{section}` below).
Route::get('layout/{page}', [ShowcaseController::class, 'layout'])
->whereIn('page', ['list-detail', 'supporting-pane', 'feed'])
->name('layout');
@@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* The showcase's own CSS, served outside the application's Vite build so its chrome the layout
* components, the text classes, `showcase.css` renders whatever an application's build contains
* (plan step 38): `all.css` (every package stylesheet) and `showcase.css`, prebuilt into
* components, the text classes, `showcase.css` renders whatever an application's build
* contains: `all.css` (every package stylesheet) and `showcase.css`, prebuilt into
* resources/dist/showcase.css (`npm run build:stylesheets`), long-cached under a content hash, and
* the two package folders a relative `url()` in it points into (the brand's woff2, `menu.css`'s
* check mark SVG). The stylesheet is served from `assets/css/`, beside `file()`'s `assets/fonts/`
@@ -111,10 +111,10 @@ class ShowcaseAssetController
* The application's Vite entries (`livewire-material.showcase.vite`) the showcase's own pages
* pass to `@vite()`: every one but a stylesheet a path Laravel's Vite itself treats as CSS
* since those pages draw their CSS from `url()`'s bundle alone. An application's CSS entry
* would load Tailwind for nothing, or the package's stylesheets a second time. Empty when the
* list names only stylesheets, and then the page skips `@vite()` altogether (it would read a
* build manifest for nothing). A JavaScript entry that imports a stylesheet itself still
* brings it: Vite links a chunk's own CSS.
* would load the application's own styling for nothing, or the package's stylesheets a second
* time. Empty when the list names only stylesheets, and then the page skips `@vite()`
* altogether (it would read a build manifest for nothing). A JavaScript entry that imports a
* stylesheet itself still brings it: Vite links a chunk's own CSS.
*
* @return list<string>
*/
+2 -2
View File
@@ -25,8 +25,8 @@ class ShowcaseController
}
/**
* One of the Layout section's own pages (plan step 38): a canonical layout on a page of its
* own, sharing the Layout section's rail entry and title.
* One of the Layout section's own pages: a canonical layout on a page of its own, sharing the
* Layout section's rail entry and title.
*/
public function layout(string $page): View
{
+30 -4
View File
@@ -3,6 +3,7 @@
namespace NoNameWeb\LivewireMaterial;
use Illuminate\Contracts\View\Factory;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
@@ -133,10 +134,28 @@ class LivewireMaterialServiceProvider extends ServiceProvider
}
/**
* Put the M3 paginators in front of Laravel's and Livewire's own. Prepended to their
* namespaces rather than set as the default view, because Livewire sets its own default on
* every render; an application's published `vendor/pagination` or `vendor/livewire` views are
* looked up before any namespace path, so they still win.
* Put the M3 paginators in front of Laravel's and Livewire's own, and take over both
* frameworks' default theme so no application forgets the config and silently renders
* Laravel's or Livewire's own Tailwind view instead.
*
* The namespaces still go through callAfterResolving('view', ): the Factory instance only
* exists once the `view` service resolves, which may happen before or after this method runs.
* `Paginator::defaultView()`/`defaultSimpleView()` and `livewire.pagination_theme`, in
* contrast, are a plain static and a config value nothing needs resolving first so they
* are set here directly, during this provider's boot(). Package providers boot before the
* application's own (its `bootstrap/providers.php` list is appended after the discovered
* package providers), so an application that calls `Paginator::defaultView()` again, or sets
* `livewire.pagination_theme` itself, in its own boot() still wins. Deferring them into the
* callback instead would risk the opposite: `view` can resolve arbitrarily late the first
* render, possibly after the application's own boot() already set its override and this
* call would silently overwrite it.
*
* `livewire.pagination_theme` is only taken over while it still reads as Livewire's own
* default, `tailwind` including when the key is missing entirely, since
* `SupportPagination::paginationView()` reads it with that same fallback. An application that
* chose another theme, or already set `material`, keeps it. Either way, a component's own
* `$paginationTheme` property or `paginationView()` method still wins over the config, because
* Livewire checks those first.
*/
protected function registerPagination(): void
{
@@ -148,6 +167,13 @@ class LivewireMaterialServiceProvider extends ServiceProvider
$view->prependNamespace('pagination', __DIR__.'/../resources/views/pagination/laravel');
$view->prependNamespace('livewire', __DIR__.'/../resources/views/pagination/livewire');
});
Paginator::defaultView('pagination::material');
Paginator::defaultSimpleView('pagination::simple-material');
if (config('livewire.pagination_theme', 'tailwind') === 'tailwind') {
config(['livewire.pagination_theme' => 'material']);
}
}
/**
+9 -9
View File
@@ -40,10 +40,10 @@ class Sections
}
}
// The Layout section's three canonical layouts (plan step 38): each is a page of its own
// rather than an anchor on the Overview, so its search entry carries its own URL; the
// page's own source joins the Layout section's for the component-homes scan below, so
// <x-list-detail>, <x-supporting-pane> and <x-feed> still find a home.
// The Layout section's three canonical layouts: each is a page of its own rather than an
// anchor on the Overview, so its search entry carries its own URL; the page's own source
// joins the Layout section's for the component-homes scan below, so <x-list-detail>,
// <x-supporting-pane> and <x-feed> still find a home.
foreach (static::layoutPages() as $key => $page) {
if ($key === 'layout') {
continue;
@@ -90,11 +90,11 @@ class Sections
}
/**
* The Layout section's own pages (plan step 38): the overview and the three canonical
* layouts, each on a page of its own, sharing one `<x-section-nav>` and the live breakpoint
* readout rather than three anchors on the Overview. They are not top-level destinations of
* their own `Sections::all()` still has one `layout` entry for the rail and the overview
* grid so this is their own small index instead.
* The Layout section's own pages: the overview and the three canonical layouts, each on a
* page of its own, sharing one `<x-section-nav>` and the live breakpoint readout rather than
* three anchors on the Overview. They are not top-level destinations of their own
* `Sections::all()` still has one `layout` entry for the rail and the overview grid so this
* is their own small index instead.
*
* @return array<string, array{title: string, icon: string, url: string}>
*/
+4 -4
View File
@@ -8,10 +8,10 @@ namespace NoNameWeb\LivewireMaterial\Support;
* nested panel), the medium and high contrast levels under `[data-contrast]` beside it, and when
* a caller gives one every colour profile again under its own prefix (`[data-scheme='name']`).
*
* `SchemeCommand::handle()` writes this to the application's `material-scheme.css`; plan step 40
* moved the shape itself here so `ErrorPage::fallbackStyles()` and the showcase's stylesheet can
* draw the same scheme, from `Scheme::forStylesheet()` rather than a generated file, without a
* second copy of the selectors to keep in step with the command's.
* `SchemeCommand::handle()` writes this to the application's `material-scheme.css`; the shape
* lives here so `ErrorPage::fallbackStyles()` and the showcase's stylesheet can draw the same
* scheme, from `Scheme::forStylesheet()` rather than a generated file, without a second copy of
* the selectors to keep in step with the command's.
*/
final class SchemeStylesheet
{
+7 -7
View File
@@ -4,19 +4,19 @@ namespace NoNameWeb\LivewireMaterial\Support;
/**
* The package's stylesheet graph as `DesignGuard::missingStylesheets()` and `unusedStylesheets()`
* (plan step 41) read it: which files a CSS entry reaches through its relative `@import`s. The
* stylesheets the package serves on its own are not bundled here but prebuilt, by
* `npm run build:stylesheets` (bin/stylesheets.mjs), into resources/dist/.
* read it: which files a CSS entry reaches through its relative `@import`s. The stylesheets the
* package serves on its own are not bundled here but prebuilt, by `npm run build:stylesheets`
* (bin/stylesheets.mjs), into resources/dist/.
*/
final class Stylesheets
{
/**
* Every file `$files` reaches, transitively, through their relative `@import`s real paths,
* `$files` themselves included without concatenating anything. An application's CSS entry
* may import what only a Vite build resolves a package name (`tailwindcss`), a URL, an import
* with a `layer()` or media condition so this never throws: an import naming a URL, an
* absolute path or a file that does not exist relative to its stylesheet is skipped, and a
* conditional import still counts as reaching its file.
* may import what only a Vite build resolves a package name (`@fontsource/roboto`), a URL,
* an import with a `layer()` or media condition so this never throws: an import naming a
* URL, an absolute path or a file that does not exist relative to its stylesheet is skipped,
* and a conditional import still counts as reaching its file.
*
* @param list<string> $files
* @return list<string>
+152 -535
View File
@@ -4,7 +4,6 @@ namespace NoNameWeb\LivewireMaterial\Testing;
use Illuminate\Support\Str;
use Illuminate\View\Compilers\ComponentTagCompiler;
use NoNameWeb\LivewireMaterial\Support\Layout;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Support\SvgFile;
use RuntimeException;
@@ -12,18 +11,16 @@ use SplFileInfo;
use Symfony\Component\Finder\Finder;
/**
* Finds what compiles to nothing in a Tailwind-free application (plan step 41), and what a
* Tailwind migration still leaves behind:
* Finds what compiles to nothing in an application built on this package:
*
* (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, position, border, effect, interactivity, text or display
* utility, a colour utility on a role, white or black (`bg-white`, `text-on-surface/60`)
* or on a colour the application's own Tailwind theme named (`bg-brand`,
* `text-sport-run/60`), 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.
* (i) any class a view or PHP file writes that no stylesheet declares not the
* application's own (`applicationClasses()`: the `.css` files among the scanned paths and
* whatever the `missingStylesheets()` entry imports outside the package), not the
* package's own (`packageClasses()`: every rule under its `resources/css`, `md-*` and the
* showcase's classes alike). So one of the two has to be in reach: a scan of views alone
* knows no application class and reports every one of them. A utility a migration left
* behind, a class of a framework that is no longer built, a typo and a class whose rule
* was deleted all read the same here: the class paints nothing.
* (ii) `missingStylesheets($cssEntry)`: a package component tag used in a view unprefixed,
* under the configured prefix, or `<x-livewire-material::…>` whose stylesheet the
* entry's relative `@import` graph does not reach (followed through every package file's
@@ -42,182 +39,36 @@ use Symfony\Component\Finder\Finder;
* 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 `var()`, or a `calc()`,
* `min()`, `max()` or `clamp()` built on one, is never flagged, whatever else it holds.
* (iv) Tailwind palette colours, 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()` 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.
* (iv) 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()` and `forbid()`, which read a line at a time, whatever it holds.
*
* A Markdown mail component a view under a path `mail.markdown.paths` names, Laravel's
* `resources/views/vendor/mail` by default is drawn by the mail theme, not by the application's
* CSS entry, and the theme's own classes (`table`, `button`, `panel`, `break-all`) share a
* Tailwind utility's name. So family (i) and the breakpoint, scale, palette and colour-value
* checks skip those views, while their icon names, the directives in their component tags and
* the application's own bans are still read; and a stylesheet under that path, a mail theme that
* has to write literal values because mail clients read no custom property, is neither check
* (iii)'s nor a source of exempt classes.
* CSS entry, so its classes (`table`, `button`, `panel`, `break-all`) are declared where this
* guard never looks. Family (i) therefore skips those views, while their icon names, the
* directives in their component tags and the application's own bans are still read; and a
* stylesheet under that path, a mail theme that has to write literal values because mail clients
* read no custom property, is neither check (iii)'s nor a source of declared classes.
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
* ->missingStylesheets(resource_path('css/app.css'))
* ->forbidColours(['tertiary'])
* ->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 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`) and a colour of the application's own Tailwind theme (`bg-brand`) are
* invisible to this guard anywhere but inside a class list.
* Each violation is "path:line what", the path relative to the base path. Family (i) only ever
* reads a class list a `.php` file writes out a `class` attribute, Livewire's
* `wire:loading.class`, Alpine's `x-transition:enter` and `:class`, `@class`, `->class()`,
* `Arr::toCssClasses()` and a `'class'` pair never a word scanned across a whole line, which
* keeps "this creates a grid of cards" from reading as a class. Inside such a list it skips a
* string a condition compares (`view === 'grid'`) and one nested deeper than the list itself, an
* array index or a call's argument (`$block['base']`, `in_array($size, ['xs', 'sm'])`). The
* trade-off: a class assembled at runtime (`'text-'.$tone`) is invisible to this guard, the
* reason to write class names out whole; and an example of markup written in a PHP comment is
* read like the real thing, since only Blade comments are stripped.
*/
class DesignGuard
{
protected const string UTILITY = '(?:bg|text|border(?:-(?:[trblxyse]|bs|be))?|ring|ring-offset|fill|stroke|from|via|to|outline|divide|decoration|caret|accent|shadow|placeholder)';
protected const string PALETTE = '(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|mauve|olive|mist|taupe)-(?:50|[1-9]00|950)';
/** A colour written as a value: an arbitrary hex, function or mix instead of a role. */
protected const string ARBITRARY_COLOUR = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/';
/**
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
* 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/1600, so a prefix maps to the
* class that carries the same intent, never to the same pixel: both of its phone breakpoints
* are M3's medium. Since 2.0.0 the prefix itself compiles to nothing there is no Tailwind
* left to read it so the hint points at the layout components' props and a plain media
* query instead of another class.
*/
protected const array WINDOW_CLASSES = [
'sm' => 'medium',
'md' => 'medium',
'lg' => 'expanded',
'xl' => 'large',
'2xl' => 'extra-large',
];
/** Tailwind's radius scale and the M3 corner it replaces each (styles §Shape). */
protected const array CORNERS = [
'none' => 'none',
'xs' => 'xs',
'sm' => 'sm',
'md' => 'md',
'lg' => 'lg',
'xl' => 'xl',
'2xl' => 'xxl',
'3xl' => 'xxl',
'4xl' => 'xxl',
'full' => 'full',
];
/** Tailwind's shadow scale and the M3 elevation level it replaces each (styles §Elevation). */
protected const array ELEVATIONS = [
'2xs' => 1,
'xs' => 1,
'sm' => 1,
'md' => 2,
'lg' => 3,
'xl' => 4,
'2xl' => 5,
];
/**
* 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 array FAMILIES = [
// Tailwind's cleared easing and duration scale: matched only here, in a class list a
// Blade or PHP file writes literally (never line-by-line across every file, the way
// outsideTheScale() still reads its other scale steps) — a plain PHP or JS string such as
// `matchMedia(…) ? 'linear' : 'ease-out'` names a real CSS keyword, not a Tailwind class,
// and reading every line for the bare word flagged both that and the word appearing inside
// a stylesheet test's own regex literal (`ease-in`, `ease-out`) as if it were one.
['/^ease-(?:in-out|linear|in|out)$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`'],
['/^duration-\d+$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`'],
['/^[a-z][a-z-]*-\((?<property>--[\w-]+)\)$/', 'Tailwind custom-property utility `%s` compiles to nothing — write `var({property})` in your own CSS'],
['/^(?:static|fixed|absolute|relative|sticky)$/', "Tailwind position utility `%s` compiles to nothing — write `position` in your own CSS (a FAB goes in `<x-scaffold>`'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: `<x-pane width>` sets a content column\'s measure, `<x-icon size>` an icon\'s; anything else is a length in your own CSS'],
['/^container$/', 'Tailwind\'s `container` compiles to nothing — use `<x-pane width>`, 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]|bs|be))?(?:-\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 `<x-divider>` or `<x-surface outlined>`; 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 `<x-surface corner="xs">`'],
['/^(?:shadow|shadow-none|shadow-inner|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-]+|accent-auto|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'],
['/^font-mono$/', 'Tailwind\'s `%s` compiles to nothing — put the value in `<code>`, `<kbd>` or `<samp>`, or use `md-mono` (text.css)'],
['/^antialiased$/', 'Tailwind\'s `%s` compiles to nothing — the foundation already smooths text in grayscale (base.css); drop it'],
['/^(?: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)|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)|text-shadow-[\w-]+(?:\/\d+)?|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]+|(?:linear|conic)-\d+|radial|conic|cover|contain|auto|center|top|bottom|left|right|(?:top|bottom)-(?:left|right)|(?:left|right)-(?:top|bottom)|no-repeat|repeat(?:-[xy]|-round|-space)?|fixed|local|scroll|none|clip-[a-z]+|origin-[a-z]+|blend-[a-z-]+)$/', 'Tailwind background utility `%s` compiles to nothing — write the rule in your own CSS'],
['/^(?:from|via|to)-(?:\d{1,3}%|none)$/', 'Tailwind gradient utility `%s` compiles to nothing — write the gradient in your own CSS'],
['/^(?:border-(?:collapse|separate)|border-spacing(?:-[xy])?-(?:\d+(?:\.\d+)?|px))$/', 'Tailwind table utility `%s` compiles to nothing — write the rule in your own CSS'],
['/^(?:(?:fill|stroke)-none|stroke-\d+)$/', 'Tailwind SVG utility `%s` compiles to nothing — write `fill` or `stroke` in your own CSS'],
];
/** Bare display utilities, matched only as a whole class-list token. */
protected const array DISPLAY_UTILITY = ['block', 'inline-block', 'inline', 'invisible', 'visible'];
/** 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',
];
/** A colour utility's prefix, up to its dash; `COLOUR_UTILITY` and `THEME_COLOUR_UTILITY` share it. */
protected const string COLOUR_PREFIX = '/^(?<utility>bg|text|border(?:-(?:[trblxyse]|bs|be))?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-';
/** A colour utility's opacity modifier (`/60`, `/[0.32]`, `/(--alpha)`), to the end of the token. */
protected const string COLOUR_OPACITY = '(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]|\(--[\w-]+\)))?$/';
/**
* A colour utility written on an M3 role, or white/black/current, with an optional opacity
* modifier (`text-on-surface/60`, `bg-scrim/[0.32]`). The roles are every `--md-sys-color-*`
* the default scheme and elevation tokens declare.
*/
protected const string COLOUR_UTILITY = self::COLOUR_PREFIX
.'(?<role>(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container|-dim|-fixed(?:-dim|-variant)?)?'
.'|inverse-(?:primary|surface|on-surface|error|success|warning|info)'
.'|(?:on-)?background|(?:on-)?surface(?:-variant|-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?'
.'|outline(?:-variant)?|scrim|shadow|white|black|current|transparent|inherit|initial)'
.self::COLOUR_OPACITY;
/**
* A colour utility on any other name (`bg-off-plan`, `text-sport-run`, `border-l-zone-4`,
* `bg-route-reference/8`): a colour the application's own Tailwind theme named before 2.0.0,
* which nothing declares now. Tried only after `COLOUR_UTILITY`, the palette and every family
* in `FAMILIES`, so a Tailwind utility sharing the prefix (`text-balance`, `border-collapse`,
* `bg-cover`, `shadow-none`) is reported as that family; the name starts with a letter, so a
* width or a stop (`border-2`, `from-10%`) never reads as one.
*/
protected const string THEME_COLOUR_UTILITY = self::COLOUR_PREFIX.'(?<name>[a-z][a-z\d]*(?:-[a-z\d]+)*)'.self::COLOUR_OPACITY;
/**
* The hooks an application writes on markup of its own, not through a component tag, whose
* rules live in a component's stylesheet, and that stylesheet (check ii). `data-md-list-row`
@@ -249,12 +100,6 @@ class DesignGuard
'inverse-on-surface' => 'md-ink-inverse',
];
/** The roles `<x-surface level>` takes. */
protected const array SURFACE_LEVELS = [
'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low',
'surface-container', 'surface-container-high', 'surface-container-highest',
];
/** 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';
@@ -312,8 +157,7 @@ class DesignGuard
* Their on-roles and containers are forbidden with them, wherever 2.0.0 lets an application
* write one: the `--md-sys-color-*` custom property (a `var()` in its CSS, an inline `style`, a
* script reading it), the `md-ink-*` class text.css has for it, and a component's `color` or
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`). A Tailwind `bg-tertiary` left behind
* is family (i)'s to report, as every colour utility is.
* `tone` prop (`color="tertiary"`, `:tone="'tertiary'"`).
*
* @param list<string> $roles
*/
@@ -345,7 +189,8 @@ class DesignGuard
* its first use. Only the imports are read here: the literal values in the application's own
* CSS are check (iii)'s, which reads the `.css` files `scan()` is given, entry or not so an
* application part-way through its migration can check its imports before its stylesheets
* are on tokens. The classes the entry's own imports declare do join check (i)'s exemptions.
* are on tokens. The classes the entry's own imports declare do count as declared for check
* (i), though.
*/
public function missingStylesheets(string $cssEntry): static
{
@@ -416,10 +261,8 @@ class DesignGuard
}
if ($readsClasses && str_ends_with($file->getFilename(), '.php')) {
foreach ($this->literalClasses($contents) as [$line, $class]) {
if (($hint = $this->tailwindFamilyHint($class)) !== null) {
$violations[] = "{$where}:{$line} {$hint}";
}
foreach ($this->undeclaredClasses($contents) as [$line, $class]) {
$violations[] = "{$where}:{$line} class `{$class}` is declared in no stylesheet — the package declares `md-*`, the rest is your own CSS";
}
}
@@ -450,13 +293,7 @@ class DesignGuard
foreach (explode("\n", $contents) as $index => $text) {
$line = $index + 1;
if ($readsClasses && preg_match_all($this->colourPattern(), $text, $matches)) {
foreach ($matches[0] as $class) {
$violations[] = "{$where}:{$line} Tailwind palette colour `{$class}` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS";
}
}
foreach ([...($readsClasses ? $this->offTheTokens($text) : []), ...$this->forbiddenRoles($text)] as $what) {
foreach ($this->forbiddenRoles($text) as $what) {
$violations[] = "{$where}:{$line} {$what}";
}
@@ -600,11 +437,6 @@ class DesignGuard
}
}
protected function colourPattern(): string
{
return '/(?<![\w-])'.self::UTILITY.'-'.self::PALETTE.'(?![\w-])/';
}
/**
* Every place one line names a role `forbidColours()` left out: its `--md-sys-color-*` custom
* property, or the `md-ink-*` class text.css draws it with.
@@ -675,320 +507,6 @@ class DesignGuard
return $found;
}
/**
* Everything on one line that names a value the theme no longer carries, each with the M3
* 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<string>
*/
protected function offTheTokens(string $text): array
{
return [
...$this->breakpointPrefixes($text),
...$this->outsideTheScale($text),
...$this->colourValues($text),
];
}
/**
* `sm:`, `max-2xl:` and the rest, stacked variants included. A prefix must be followed at
* once by the next variant or the utility itself, so a `md:` that is really an object key
* (`md: { … }` in a script) is left alone.
*
* @return list<string>
*/
protected function breakpointPrefixes(string $text): array
{
static $pattern = null;
$pattern ??= '/(?<![\w-])(?<max>max-)?(?<name>'.$this->alternation(array_keys(self::WINDOW_CLASSES)).'):(?=[a-z\d!*\[(_-])/';
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
return array_map(function (array $match): string {
$max = $match['max'] ?? '';
$name = self::WINDOW_CLASSES[$match['name']];
$px = Layout::BREAKPOINTS[$name];
$comparison = $max !== '' ? "width < {$px}px" : "width >= {$px}px";
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 and tracking utilities Tailwind shipped and
* M3's own scales replace. The easing and duration steps of the same Tailwind scale are not
* read here see the class header's note by the `ease-*`/`duration-*` entries of `FAMILIES`.
*
* @return list<string>
*/
protected function outsideTheScale(string $text): array
{
static $pattern = null;
$pattern ??= '/(?<![\w-])(?:'
.'rounded(?<side>-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-(?<corner>'.$this->alternation(array_keys(self::CORNERS)).')'
.'|shadow-(?<elevation>'.$this->alternation(array_keys(self::ELEVATIONS)).')'
.'|text-(?<size>xs|sm|base|lg|xl|[2-9]xl)'
.'|font-(?<weight>thin|extralight|light|normal|medium|semibold|bold|extrabold|black)'
.'|leading-(?<leading>none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)'
.'|tracking-(?<tracking>tighter|tight|normal|wider|widest|wide)'
.')(?![\w-])/';
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]}` — ".$this->scaleReplacement($match),
$matches,
);
}
/**
* 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<array-key, string|null> $match
*/
protected function scaleReplacement(array $match): string
{
return match (true) {
isset($match['corner']) => 'use `var(--md-sys-shape-corner-'.self::CORNERS[$match['corner']].')` in your own CSS, or `<x-surface corner="'.self::CORNERS[$match['corner']].'">`',
isset($match['elevation']) => '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',
default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
};
}
/**
* Colours written as a value rather than a role: an arbitrary hex, function or mix.
*
* @return list<string>
*/
protected function colourValues(string $text): array
{
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
return array_map(fn (string $class): string => "arbitrary colour `{$class}`, use an M3 role", $matches[0]);
}
/**
* 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), 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
{
if ($token === '' || str_starts_with($token, 'md-') || isset($this->applicationClasses()[$token])) {
return null;
}
$token = trim($token, '!');
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 (preg_match('/^(?<lead>\[[^\]]*\]|[^:\[]+(?:\[[^\]]*\])?):/', $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 — ".$this->variantHint($variant['lead']);
}
if (preg_match('/^\[(?<property>-{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 (($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, the `hidden` attribute (the reset keeps it hidden), or `x-show`";
}
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 (($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 $this->themeColourHint($token);
}
/**
* The 2.0.0 replacement for a colour utility on a name neither M3 nor Tailwind has (see
* `THEME_COLOUR_UTILITY`), or null: the application's own custom property in its own CSS, mixed
* toward transparent where the utility carried an opacity.
*/
protected function themeColourHint(string $token): ?string
{
if (preg_match(self::THEME_COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
return null;
}
$dead = "Tailwind colour utility `{$token}` compiles to nothing — `{$m['name']}` is neither an M3 role nor a Tailwind colour, so it named a colour of the application's own theme";
return $m['opacity'] !== null
? "{$dead}: use `color-mix(in srgb, var(--…) <n>%, transparent)` in your own CSS"
: "{$dead}: use `var(--…)` in your own CSS";
}
/**
* 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, or an arbitrary selector.
*/
protected function variantHint(string $lead): string
{
$bare = str_starts_with($lead, 'max-') ? substr($lead, 4) : $lead;
return match (true) {
$bare !== 'compact' && isset(Layout::BREAKPOINTS[$bare]) => '`@media (width '.($bare === $lead ? '>=' : '<').' '.Layout::BREAKPOINTS[$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 `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column",
$token === 'flex-col' => "{$dead} — use `<x-stack>` (`gap`, `align`)",
$token === 'flex-wrap' => "{$dead} — use `<x-row wrap>`",
in_array($token, ['grid', 'inline-grid'], true) => "{$dead} — use `<x-grid>` (`:columns` per breakpoint, `gap`, `min-item`), or `<x-feed>` for a grid of cards",
preg_match('/^grid-cols-(\d+)$/', $token, $m) === 1 => "{$dead} — use `<x-grid :columns=\"{$m[1]}\">`, 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 `<x-row>` or `<x-stack>`",
$token === 'items-baseline' => "{$dead} — use `align=\"baseline\"` on `<x-row>`",
preg_match('/^justify-(start|end|center|between)$/', $token, $m) === 1 => "{$dead} — use `justify=\"{$m[1]}\"` on `<x-row>`",
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('/^(?<negative>-)?(?<kind>gap(?:-[xy])?|[pm][trblxyse]?|space-[xy])-(?<value>\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} — `<x-pane width>` 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 `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
$m['kind'] === 'space-y' => "{$dead} — use `<x-stack>` with ".$choice('gap'),
$m['kind'] === 'space-x' => "{$dead} — use `<x-row>` with ".$choice('gap'),
str_starts_with($m['kind'], 'p') => "{$dead} — use ".$choice('padding')." on `<x-surface>`, 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",
};
}
/**
* A colour utility's 2.0.0 replacement (see `COLOUR_UTILITY`), or null for anything else: an
* ink on plain text is its `md-ink-*` class, a line `<x-divider>` or `<x-surface outlined>`, a
* tonal background `<x-surface level>`, white and black a role, opacity a role or the state
* layer, and every other role its `var(--md-sys-color-*)` in the application's own CSS.
*/
protected function colourUtilityHint(string $token): ?string
{
if (preg_match(self::COLOUR_UTILITY, $token, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
return null;
}
$utility = $m['utility'];
$role = $m['role'];
$dead = "Tailwind colour utility `{$token}` compiles to nothing";
return match (true) {
in_array($role, ['white', 'black'], true) => "{$dead} — M3 paints with roles, never white or black: `<x-surface level=\"surface-container-lowest\">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink",
in_array($role, ['current', 'transparent', 'inherit', 'initial'], true) => "{$dead} — write `".($role === 'current' ? 'currentColor' : $role).'` in your own CSS',
$m['opacity'] !== null && $utility === 'text' => "{$dead} — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
$m['opacity'] !== null => "{$dead} — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-{$role}) <n>%, transparent)` in your own CSS",
$utility === 'text' && isset(self::INK_ROLE[$role]) => "{$dead} — use `".self::INK_ROLE[$role].'` (text.css)',
preg_match('/^(?:border(?:-(?:[trblxyse]|bs|be))?|divide)$/', $utility) === 1 && in_array($role, ['outline', 'outline-variant'], true) => "{$dead} — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility",
$utility === 'bg' && in_array($role === 'background' ? 'surface' : $role, self::SURFACE_LEVELS, true) => "{$dead} — use `<x-surface level=\"".($role === 'background' ? 'surface' : $role).'">`',
default => "{$dead} — use `var(--md-sys-color-{$role})` in your own CSS",
};
}
/**
* 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
@@ -1285,14 +803,15 @@ class DesignGuard
}
/**
* 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.
* Every class written out literally: in a `class` attribute, in 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'`) is not a class, and neither is one
* nested deeper than the list itself an array index or a call's argument, which is where
* `$block['base']`, `$header['class']` and `in_array($size, ['xs', 'sm'])` keep their strings;
* echoes inside a list are skipped. A token is returned exactly as written, prefix and all,
* with the line it sits on.
*
* @return list<array{0: int, 1: string}>
*/
@@ -1312,6 +831,13 @@ class DesignGuard
$alpine = $binding['alpine'][0] !== null;
[$body, $offset] = $binding['alpine'][0] !== null ? $binding['alpine'] : ($binding['php'][0] !== null ? $binding['php'] : $binding['pair']);
// `:class` on a component is PHP, so it may wrap the very helpers `directiveInTag()`
// recommends: the array inside is the list, the call around it is not.
if ($alpine && preg_match('/^\s*[^\s(]*(?:Arr::toCssClasses|->class)\(\s*(\[.*\])\s*\)\s*$/s', $body, $call, PREG_OFFSET_CAPTURE) === 1) {
[$body, $offset] = [$call[1][0], $offset + $call[1][1]];
$alpine = false;
}
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) {
@@ -1319,7 +845,9 @@ class DesignGuard
$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) {
if (preg_match('/(?:[=!]==?|[<>]=?|\(|\?\?)\s*$/', $before) === 1
|| preg_match('/^\s*(?:[=!]==?|[<>]=?)/', $after) === 1
|| $this->readsAValue($before)) {
continue;
}
@@ -1343,6 +871,57 @@ class DesignGuard
return $found;
}
/**
* Check (i): every class `literalClasses()` reads out of `$contents` that neither the
* application's own stylesheets nor the package's declare a rule for it exists nowhere, so
* the class paints nothing, whatever it was once meant to do.
*
* @return list<array{0: int, 1: string}>
*/
protected function undeclaredClasses(string $contents): array
{
$found = [];
foreach ($this->literalClasses($contents) as [$line, $class]) {
if ($class === '' || isset($this->applicationClasses()[$class]) || isset($this->packageClasses()[$class])) {
continue;
}
$found[] = [$line, $class];
}
return $found;
}
/**
* Whether the string that follows `$before` the text of a class list up to it is a value
* the list reads rather than a class it writes: a call's argument (`in_array($size, ['xs',
* 'sm'])`) or an array index (`$block['base']`, `$header['class']`). Both open a delimiter
* that is still unclosed where the string sits, so this walks the text and keeps what is open:
* a `(` right after a name is a call, one after anything else groups an expression
* (`$cond ? 'a' : ($other ? 'b' : 'c')`), and a `[` right after a name, a `]` or a `)` reads
* an index, while any other `[` opens an array of classes.
*/
protected function readsAValue(string $before): bool
{
$open = [];
$length = strlen($before);
for ($i = 0; $i < $length; $i++) {
$previous = rtrim(substr($before, 0, $i));
$name = $previous !== '' && preg_match('/[\w\]\)]$/', $previous) === 1;
match ($before[$i]) {
'(' => $open[] = $name ? 'call' : 'group',
'[' => $open[] = $name ? 'index' : 'array',
')', ']' => array_pop($open),
default => null,
};
}
return in_array('call', $open, true) || in_array('index', $open, true);
}
/**
* Literal symbol names that do not exist: `<x-icon name="…">`, and an `icon="…"` or
* `icon-right="…"` on any component.
@@ -1686,9 +1265,7 @@ class DesignGuard
/**
* Every class an application's own stylesheets select on the scanned ones and whatever the
* CSS entry imports outside the package 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.
* An escaped selector (`.sm\:flex`) counts under its unescaped name.
* CSS entry imports outside the package one half of what check (i) counts as declared.
*
* @return array<string, true>
*/
@@ -1710,18 +1287,57 @@ class DesignGuard
}
foreach ($files as $file) {
$css = $this->maskedCss((string) file_get_contents($file));
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $css, $matches);
foreach ($matches[1] as $class) {
$classes[stripslashes($class)] = true;
}
$classes = [...$classes, ...$this->classSelectors((string) file_get_contents($file))];
}
return $this->applicationClassesCache = $classes;
}
/**
* Every class the package's own `resources/css` declares `md-*`, the component rules and the
* showcase's own classes alike — the other half of check (i)'s "declared". Whether an
* application imports the stylesheet that holds the rule is check (ii)'s question, not this
* one's. Cached for the process: the package's CSS is the same for every scan in it.
*
* @return array<string, true>
*/
protected function packageClasses(): array
{
static $classes = null;
if ($classes !== null) {
return $classes;
}
$classes = [];
$root = static::packagePath('css');
foreach ($root === '' ? [] : Finder::create()->files()->in($root)->name('*.css') as $file) {
$classes = [...$classes, ...$this->classSelectors((string) file_get_contents($file->getPathname()))];
}
return $classes;
}
/**
* The classes `$css` selects on, by their unescaped name (`.sm\:flex` counts as `sm:flex`).
* Comments and strings are blanked out first, so `content: ".flex"` declares nothing.
*
* @return array<string, true>
*/
protected function classSelectors(string $css): array
{
preg_match_all('/(?<![\w.#\\\\-])\.(-?(?:[a-zA-Z_]|\\\\.)(?:[\w-]|\\\\.)*)/', $this->maskedCss($css), $matches);
$classes = [];
foreach ($matches[1] as $class) {
$classes[stripslashes($class)] = true;
}
return $classes;
}
protected function isGeneratedScheme(string $file): bool
{
if (basename($file) === 'material-scheme.css') {
@@ -1733,7 +1349,8 @@ class DesignGuard
/**
* 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.
* (check iii) never come from the package's own rules, and its own classes are
* `packageClasses()`, read separately.
*/
protected static function isPackageFile(string $file): bool
{
+7 -9
View File
@@ -687,11 +687,9 @@ it('toggles each choice of a multi selection group, down to none', function () {
});
/**
* Browser tests owed from docs/plans/material-3-browser-tests.md § Actions (Phase E), written on
* the data-md-* hooks of plan step 36's actions stream: submenu keyboard and hover-open, the
* filtering menu, the sheet-at-compact list, a long menu's scroll, and the FAB menu's own scroll
* behind its fixed close button. Written but not run the actions group's Chromium run happens
* once every stream in the group has landed.
* The tests below: a submenu opened and closed by keyboard or hover, a menu's filter field, its
* sheet-at-compact form below 600px, a long menu's scroll and the FAB menu's own scroll behind
* its fixed close button. `LongMenuProbe` and `SheetMenuProbe` back the last three of those.
*/
/** A Livewire component whose menu has more items than the popover's 288px cap can show. */
@@ -872,10 +870,10 @@ it('filters a menu\'s items as its field is typed into, moves the highlight with
it('opens a sheet-at-compact menu below 600px, focused on its first item, and the popover from 600px', function () {
$trigger = '#menus button:has-text("Photo")';
// The sheet is <x-bottom-sheet>, rewritten (plan step 36) but still x-show-driven — its CSS
// now animates open/close through @starting-style and transition-behavior: allow-discrete
// rather than Alpine's x-transition classes, but x-show still owns the element's display, so
// its openness shows as visibility, not the Popover API's :popover-open.
// The sheet is <x-bottom-sheet>, still x-show-driven — its CSS now animates open/close
// through @starting-style and transition-behavior: allow-discrete rather than Alpine's
// x-transition classes, but x-show still owns the element's display, so its openness shows
// as visibility, not the Popover API's :popover-open.
$dialog = '[role="dialog"][aria-label="Photo actions"]';
$addToAlbum = '#menus [role="menuitem"]:has-text("Add to album")';
+3 -3
View File
@@ -187,9 +187,9 @@ it('scrolls instantly and leaves every item unmasked under reduced motion', func
});
it('brings an item cut off at the row\'s edge into view when it is pressed under reduced motion', function () {
// Owed by the Chromium baseline (step 32, docs/plans/material-3-browser-tests.md): reduced
// motion writes a zero inset for every item, so a mask cannot say an item is only partly
// shown; carousel.js reads the item's box against the row's instead, as for multi-aspect.
// Under reduced motion, the row writes a zero inset for every item, so a mask cannot say an
// item is only partly shown; carousel.js reads the item's box against the row's instead, as
// for multi-aspect.
$page = carouselShowcase(['reducedMotion' => 'reduce'])
->assertScript(onCarousel(0, <<<'JS'
const row = scroller.getBoundingClientRect()
+9 -9
View File
@@ -7,13 +7,13 @@ use Livewire\Component;
use Livewire\Livewire;
/**
* The central claims of the `@layer material` design (plan step 42, docs/plans/material-3-alignment.md
* Decisions § "Components carry their styles as `data-md-*` attributes inside `@layer material`"),
* each on its own small probe page: an application's unlayered rule beats a component default;
* `[hidden]` beats a component's `display`; the layer order holds even when a component file is
* imported before the foundation; a `data-md-*` attribute survives a Livewire `$refresh`; an icon
* mirrors in a right-to-left document. Every other stylesheet claim (layers, imports, breakpoints,
* no Tailwind) is StylesheetsTest.php's, asserted on the source rather than a rendered page.
* The central claims of the `@layer material` design components carry their styles as
* `data-md-*` attributes inside `@layer material` each on its own small probe page: an
* application's unlayered rule beats a component default; `[hidden]` beats a component's
* `display`; the layer order holds even when a component file is imported before the foundation;
* a `data-md-*` attribute survives a Livewire `$refresh`; an icon mirrors in a right-to-left
* document. Every other stylesheet claim (layers, imports, breakpoints) is StylesheetsTest.php's,
* asserted on the source rather than a rendered page.
*/
function cascadeReady(mixed $page): mixed
{
@@ -71,8 +71,8 @@ it('lets [hidden] beat a component\'s display', function () {
});
it('keeps the layer order even when a component file is imported before the foundation', function () {
// Every package stylesheet opens with the same full `@layer` statement (plan step 33), so the
// order it establishes holds whichever file a page reaches first — the error layout's copy of
// Every package stylesheet opens with the same full `@layer` statement, so the order it
// establishes holds whichever file a page reaches first — the error layout's copy of
// it, reached here before the foundation's. The two prebuilt error-page stylesheets, in that
// deliberate order: the layout's bundle, whose button.css rules sit in `material.components`,
// physically precedes the fallback's foundation reset, in `material.reset` — and still loses
+2 -3
View File
@@ -1077,9 +1077,8 @@ it('slides the side sheet in from its own edge in a right-to-left page', functio
// `side="end"` (the default) is the trailing edge — the *left* of a right-to-left page — so it
// slides in from off the left, not the right (the CSS mirror reads `[dir='rtl']` rather than
// `:dir(rtl)`, plan step 39 — Vite's own minifier rewrote that to a `:lang()` list, same as
// Tailwind's build did): caught well before the 500ms spatial transition ends, it is still
// off-screen on the left.
// `:dir(rtl)`, because Vite's own minifier rewrites that to a `:lang()` list): caught well
// before the 500ms spatial transition ends, it is still off-screen on the left.
$page->wait(0.05);
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
+4 -4
View File
@@ -6,10 +6,10 @@ use Livewire\Component;
use Livewire\Livewire;
/**
* The layout components at M3's breakpoints (plan step 35): each on a page of its own, measured at
* the pixel either side of the breakpoint it changes at. The window is resized after the page has
* loaded, which is what a person does; a media query answers at once, and a script listening to
* one (resources/js/layout.js) hears a change event.
* The layout components at M3's breakpoints: each on a page of its own, measured at the pixel
* either side of the breakpoint it changes at. The window is resized after the page has loaded,
* which is what a person does; a media query answers at once, and a script listening to one
* (resources/js/layout.js) hears a change event.
*/
function layoutPage(string $body, int $width, int $height = 900, string $dir = 'ltr'): mixed
{
+1 -1
View File
@@ -350,7 +350,7 @@ it('lifts the snackbar and the page above something docked on the phone\'s bar',
* A page for the navigation bar and rail props the shell's own adaptive rail never exercises:
* `tall`, `hide-on-scroll` (with a pinned snackbar), a narrow centred rail, and a rail that hides
* when collapsed. A wide, fixed viewport throughout, so none of M3's breakpoints interfere with
* what each example's own props already decide (docs/plans/material-3-browser-tests.md § Navigation).
* what each example's own props already decide.
*/
function navigationExtrasProbe(): mixed
{
+32 -86
View File
@@ -1,13 +1,14 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
/**
* The texts Boost copies into an application: the two guidelines every session reads, and the two
* skills. Each teaches the vocabulary an agent then writes, so none may name a class or variant
* the package's stylesheets do not define.
* skills. A fenced Blade, HTML, PHP or JS block goes through the design guard whole, so none may
* write a class no stylesheet declares; in prose, only an inline span that reads as an `md-*`
* class list is checked (`boostSpanIsClassList()`), so a name from another vocabulary a prop, a
* role, a config key is never held to that standard.
*/
const BOOST_TEXTS = [
'resources/boost/guidelines/core.blade.php',
@@ -16,16 +17,10 @@ const BOOST_TEXTS = [
'resources/boost/skills/material-3-design/SKILL.md',
];
/**
* The one section that names Tailwind's utilities on purpose: the design guard's table of what it
* reports, each beside its replacement.
*/
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
/**
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
* list (`bg-primary`, `medium:hidden`, `md-ink-variant`) as a `class` attribute on its own line.
* and JS into a view, fenced CSS and an inline rule into a stylesheet, and every inline span that
* reads as a class list (`md-ink-variant md-truncate`) as a `class` attribute on its own line.
* Prose, props, attributes and custom properties are left out.
*
* @return array{view: string, css: string}
@@ -35,7 +30,6 @@ function boostTextAsCode(string $text): array
$view = [];
$css = [];
$fence = null;
$skipping = false;
foreach (explode("\n", $text) as $line) {
$viewLine = '';
@@ -44,28 +38,28 @@ function boostTextAsCode(string $text): array
if (preg_match('/^```\s*([a-z]*)/', $line, $open) === 1) {
$fence = $fence === null ? ($open[1] ?: 'text') : null;
} elseif ($fence !== null) {
if (! $skipping && $fence === 'css') {
if ($fence === 'css') {
$cssLine = $line;
} elseif (! $skipping && in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
} elseif (in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
$viewLine = $line;
}
} else {
if (preg_match('/^## (.+)$/', $line, $heading) === 1) {
$skipping = in_array($heading[1], BOOST_SECTIONS_NAMING_WHAT_FAILS, true);
}
preg_match_all('/`([^`]+)`/', $line, $spans);
if (! $skipping) {
preg_match_all('/`([^`]+)`/', $line, $spans);
// A span that is a rule of its own (`.sport-run-label { … }`) declares the class
// the example beside it writes, so it goes on the stylesheet side of the pair.
[$rules, $spans] = collect($spans[1])->partition(fn (string $span): bool => preg_match('/^\s*\.[\w-]+[^{]*\{[^}]*\}\s*$/', $span) === 1);
$viewLine = collect($spans[1])
->map(fn (string $span): ?string => match (true) {
str_contains($span, '<') => $span,
boostSpanIsClassList($span) => '<div class="'.$span.'"></div>',
default => null,
})
->filter()
->implode(' ');
}
$cssLine = $rules->implode(' ');
$viewLine = $spans
->map(fn (string $span): ?string => match (true) {
str_contains($span, '<') => $span,
boostSpanIsClassList($span) => '<div class="'.$span.'"></div>',
default => null,
})
->filter()
->implode(' ');
}
$view[] = $viewLine;
@@ -76,70 +70,22 @@ function boostTextAsCode(string $text): array
}
/**
* The hyphenated names the texts also write alone in a span for something that is not a class:
* every property the package's stylesheets declare (`border-color`), every component prop
* (`placeholder-value`) and every colour role (`outline-variant`). Each is shaped like a colour
* utility on a name the guard does not know, which is what an application's own theme colour
* (`bg-brand`) looks like.
*
* @return array<string, true>
*/
function boostNamesThatAreNotClasses(): array
{
static $names = null;
if ($names !== null) {
return $names;
}
$names = [];
$root = __DIR__.'/../../resources';
foreach (File::allFiles("{$root}/css") as $file) {
preg_match_all('/(?<![\w-])([a-z][a-z-]*-[a-z-]+)\s*:\s*[^;{}]*[;}]|--md-sys-color-([a-z-]+)/', $file->getContents(), $matches);
foreach ([...$matches[1], ...$matches[2]] as $name) {
$names[$name] = true;
}
}
foreach (File::files("{$root}/views/components") as $file) {
if (preg_match('/@props\(\[(.*?)\]\)/s', $file->getContents(), $props) === 1) {
preg_match_all("/^\s*'(\w+)'/m", $props[1], $keys);
foreach ($keys[1] as $key) {
$names[Str::kebab($key)] = true;
}
}
}
return $names;
}
/**
* Whether an inline span reads as a class list. Not: a CSS declaration (`font: var(…)`), a
* command (`php artisan …`), a single bare word, which is as often a role, prop or value
* (`outline`, `link`, `hidden`) as a utility, or a single property, prop or role name
* (`border-color`, `placeholder-value`, `outline-variant`).
* Whether an inline span reads as a class list: every token in it, and there has to be at least
* one, has to be an `md-*` class the only vocabulary this check holds prose to. A single bare
* word (`outline`, `link`, `hidden`), a property or prop name (`border-color`,
* `placeholder-value`), a colour role (`outline-variant`), a CSS function (`var(…)`) and a shell
* command (`php artisan …`) all fail that test on their own, with no list of names to keep here.
*/
function boostSpanIsClassList(string $span): bool
{
$tokens = preg_split('/\s+/', trim($span));
if (count($tokens) === 1 && (preg_match('/[-:]/', $tokens[0]) !== 1 || isset(boostNamesThatAreNotClasses()[$tokens[0]]))) {
return false;
}
if (preg_match('/^(?:php|npm|npx|composer|git)\b|\b(?:var|calc|color-mix)\(|^[a-z-]+:\s/', $span) === 1) {
return false;
}
foreach ($tokens as $token) {
if (preg_match('/^(?:--|data-|aria-|wire:|x-|@|\$)/', $token) === 1 || str_contains($token, '::')) {
return false;
}
if (preg_match('/^[a-z0-9][a-z0-9:\/.\[\]()#%_-]*$/', $token) !== 1) {
// Every class the package declares is `md-*`, so that prefix is what tells a class list
// from the prop values, slot names and config keys the prose lists the same way
// (`top-start`, `rail-header`, `livewire-material.profiles`). A class of an application's
// own that an example names is nobody's to declare here.
if (preg_match('/^(?:[a-z][\w-]*:)*md-[a-z\d-]+$/', $token) !== 1) {
return false;
}
}
-118
View File
@@ -1,118 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
/**
* Breakpoints are M3's window size classes and only those: compact below `medium`, then medium 600,
* expanded 840, large 1200 and extra-large 1600 (docs/reference/m3/foundations.md § Layout,
* foundations-supplement.md § Breakpoints). Tailwind is gone from the whole stack (plan step 39),
* so a leftover `sm:` class or a media query at one of its screen widths no longer compiles to
* nothing a browser just as silently ignores the invalid class or the wrong-width rule, so the
* mistake is exactly as easy to miss. These two scans are the guard: one for a Tailwind-shaped
* prefix (a habit, or an example pasted from elsewhere), one for the media queries a stylesheet or
* a script writes by hand.
*
* @return list<SplFileInfo>
*/
function breakpointSources(): array
{
return collect(['views', 'css', 'js'])
->flatMap(fn (string $directory): array => File::allFiles(__DIR__.'/../../resources/'.$directory))
->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'css', 'js'], true))
->values()
->all();
}
/**
* Every line of a file that matches, as `path:line: the line`.
*
* @return list<string>
*/
function breakpointOffenders(SplFileInfo $file, string $pattern): array
{
$lines = preg_split('/\R/', $file->getContents());
return collect($lines)
->filter(fn (string $line): bool => preg_match($pattern, $line) === 1)
->map(fn (string $line, int $index): string => $file->getRelativePathname().':'.($index + 1).': '.trim($line))
->values()
->all();
}
it('writes no Tailwind breakpoint prefix in any view, stylesheet or script', function () {
// A prefix, not a name: `--radius-corner-sm:` and slider.js's `sm: { corner: 8 }` are excluded
// by the character before and the one after, and `@md:` is a container query, a different
// thing — a component sized by the room it has, not by the window.
$pattern = '/(?<![\w@-])(?:max-)?(?:sm|md|lg|xl|2xl):[a-z\[(-]/';
$offenders = collect(breakpointSources())
->flatMap(fn (SplFileInfo $file): array => breakpointOffenders($file, $pattern))
->values();
expect($offenders->all())->toBe([]);
});
it('writes no media query at a Tailwind screen width', function () {
// 40rem/640px, 48rem/768px, 64rem/1024px, 80rem/1280px and the 39.99rem that stood for "below
// 640" — none of them is an M3 boundary. The M3 ones are 37.5, 52.5, 75 and 100rem, and a
// script asks resources/js/breakpoints.js for them instead of writing its own string.
$widths = '/(?<![\d.])(?:40|48|64|80|39\.99)rem|(?<![\d.])(?:640|768|1024|1280)px/';
$offenders = collect(breakpointSources())
->flatMap(function (SplFileInfo $file) use ($widths): array {
// Only the condition of a query, so `max-width: min(40rem, 70dvh)` — a real width, not
// a breakpoint — and a 640px cap on a bottom sheet are left alone.
preg_match_all('/@(?:media|container)\b[^{]*|matchMedia\(\s*[\'"`][^\'"`]*/', $file->getContents(), $matches);
return collect($matches[0])
->filter(fn (string $condition): bool => preg_match($widths, $condition) === 1)
->map(fn (string $condition): string => $file->getRelativePathname().': '.trim($condition))
->values()
->all();
})
->values();
expect($offenders->all())->toBe([]);
});
it('scans the showcase example heredocs too, where the package teaches its own markup', function () {
$sections = collect(breakpointSources())
->filter(fn (SplFileInfo $file): bool => str_contains($file->getRelativePathname(), 'showcase'.DIRECTORY_SEPARATOR.'sections'))
->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), "<<<'BLADE'"));
// Every section still teaches its examples through a heredoc nested inside an @php block
// (plan step 38 rewrote what is inside them, not the mechanism), so the two scans above —
// which read a file's raw text rather than its parsed Blade or PHP — still reach them.
expect($sections)->not->toBeEmpty();
// The premise itself, proven on a fixture now that plan step 38 has cleared every Tailwind
// breakpoint prefix out of the showcase (its own goal, so production content can no longer
// carry one): a real file, shaped exactly like a showcase section (an outer heredoc nesting an
// inner one, `@php … <<<'BLADE' … BLADE, … @endphp`), still gives up a prefix hiding two
// heredocs deep to the same pattern and helper the first test above uses.
$directory = sys_get_temp_dir().'/breakpoint-heredoc-probe-'.uniqid();
File::ensureDirectoryExists($directory);
// Built from parts, not written whole: the design guard's own "no Tailwind utility class in
// resources/ or tests/" scan (StylesheetsTest.php) reads this file's raw source too, and a
// real Tailwind breakpoint prefix sitting in a class attribute here would be exactly what
// that scan looks for — the placeholder keeps this file's own source clean of one.
File::put($directory.'/probe.blade.php', str_replace('__BREAKPOINT__', 'md:', <<<'OUTER'
@php
$examples = [
'Probe' => <<<'BLADE'
<div class="__BREAKPOINT__flex"></div>
BLADE,
];
@endphp
OUTER));
try {
$pattern = '/(?<![\w@-])(?:max-)?(?:sm|md|lg|xl|2xl):[a-z\[(-]/';
$probe = File::allFiles($directory)[0];
expect(breakpointOffenders($probe, $pattern))->not->toBeEmpty();
} finally {
File::deleteDirectory($directory);
}
});
@@ -1,11 +1,10 @@
<?php
/**
* A caller's `class` and `style` land on a component's root, untouched and once (the Phase F rule
* in docs/plans/material-3-alignment-brief.md): an application's width or margin sizes the whole
* component, whichever element its other attributes reach. The field family, the pickers and the
* components that split their attributes between a root and an inner control are the ones that
* can get it wrong, so each is rendered here with both.
* A caller's `class` and `style` land on a component's root, untouched and once: an application's
* width or margin sizes the whole component, whichever element its other attributes reach. The
* field family, the pickers and the components that split their attributes between a root and an
* inner control are the ones that can get it wrong, so each is rendered here with both.
*/
it('lands a caller\'s class and style on the root, and only there', function (string $blade) {
$html = (string) $this->blade($blade);
@@ -5,16 +5,16 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* Every component and layout-shared stylesheet, rewritten without Tailwind (plan step 36): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as
* px range queries, and is imported from its own block of all.css. This used to be four near-copies
* of the same checks, one per group (Action/Containment/Input/Navigation), plus a fifth in
* ErrorPagesTest.php for the error layout folded into this one file (plan cleanup) because the
* checks never differed in substance, only in which names they ran over and, for the token check,
* which box-shadow shape a group's own values may take (see $boxShadow below). `icon` and `shape`
* (no group's view renders them exclusively) and `color` (its own ColorTest.php already covers it,
* the shared colour-role table every hue-taking component imports) keep their separate treatment.
* Every component and layout-shared stylesheet: each view renders `data-md-*` attributes and no
* class list of its own beyond the interaction and text classes (tests/Support/ViewClasses.php),
* writes its values from the tokens and its breakpoints as px range queries, and is imported from
* its own block of all.css. This used to be four near-copies of the same checks, one per group
* (Action/Containment/Input/Navigation), plus a fifth in ErrorPagesTest.php for the error layout
* folded into this one file (plan cleanup) because the checks never differed in substance, only in
* which names they ran over and, for the token check, which box-shadow shape a group's own values
* may take (see $boxShadow below). `icon` and `shape` (no group's view renders them exclusively)
* and `color` (its own ColorTest.php already covers it, the shared colour-role table every
* hue-taking component imports) keep their separate treatment.
*
* Four names in the dataset are not a component view of their own: `selection` (what checkbox,
* radio and toggle share), `navigation-item` (what the bar and the rail's items share), `pagination`
@@ -103,9 +103,9 @@ dataset('scoped selectors', function (): Generator {
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
// The header, the layer statement, the plain imports and freedom from Tailwind are
// StylesheetsTest's, checked over every file all.css reaches; only the block layer and the
// imports' existence are this dataset's own to check.
// The header, the layer statement and the plain imports are StylesheetsTest's, checked over
// every file all.css reaches; only the block layer and the imports' existence are this
// dataset's own to check.
expect($css->blocks())->each->toBe('@layer material.components');
foreach ($css->imports() as $import) {
@@ -173,10 +173,10 @@ it('scopes every element-wide selector to a data-md hook', function (string $nam
// ---- pagination: its views live outside resources/views/components/ --------------------------
dataset('pagination views', [
'pagination/laravel/tailwind',
'pagination/laravel/simple-tailwind',
'pagination/livewire/tailwind',
'pagination/livewire/simple-tailwind',
'pagination/laravel/material',
'pagination/laravel/simple-material',
'pagination/livewire/material',
'pagination/livewire/simple-material',
]);
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
@@ -103,7 +103,7 @@ it('hides on a scroll down and springs back on a scroll up', function () {
->toBe(['translate' => '0 100%'])
// Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
// `material.layout`, which this rule's own `material.components` always outranks, so both
// sit in a layer now (the era of an unlayered Tailwind utility is gone).
// sit in a layer.
->and($css->declarations('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden])'))
->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
@@ -143,14 +143,14 @@ it('hides a collapsible or adaptive rail entirely when told to, and only those',
});
/**
* The Tailwind-era `rail-collapsed:` custom variant had seven branches for "this rail is drawn
* collapsed"; navigation-rail.css merges the three that carry no media query into one `:where()`
* (provably the same match set: an element matches `X:where(A, B, C)` exactly when it matches
* `X:where(A)` or `X:where(B)` or `X:where(C)`) and keeps the four media-gated ones separate, since
* CSS cannot merge different `@media` queries into one rule. This pins all five physical branches
* and so every one of the seven original conditions for the rail's own width, the property
* every branch sets, and for `--md-navigation-rail-value`, which each branch turns from `expanded`
* to `collapsed` so an application's style query answers exactly when the rail's own shape does.
* "This rail is drawn collapsed" has seven conditions in all; navigation-rail.css merges the three
* that carry no media query into one `:where()` (provably the same match set: an element matches
* `X:where(A, B, C)` exactly when it matches `X:where(A)` or `X:where(B)` or `X:where(C)`) and keeps
* the four media-gated ones separate, since CSS cannot merge different `@media` queries into one
* rule. This pins all five physical branches and so every one of the seven original conditions
* for the rail's own width, the property every branch sets, and for `--md-navigation-rail-value`,
* which each branch turns from `expanded` to `collapsed` so an application's style query answers
* exactly when the rail's own shape does.
*/
it('holds a closing rail on screen for its exit rather than transitioning display', function () {
$css = ComponentStylesheet::read('navigation-rail');
+1 -1
View File
@@ -24,7 +24,7 @@ it('draws a search bar whose combobox controls the view', function () {
->toContain('materialSearch(false,')
->not->toContain('data-md-search-results')
// Split so the string never reads as a literal class="..." attribute to the design guard's
// own scan of this file — Tailwind's `relative` utility, which the field used to carry.
// own scan of this file — `relative`, the utility class the field used to carry.
->not->toContain('class="'.'relative"');
// The state belongs to the combobox, never to the bare textbox ARIA gives it to.
+71 -393
View File
@@ -1,6 +1,5 @@
<?php
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
@@ -16,6 +15,15 @@ function fixtureRelative(array $violations): array
return array_map(fn (string $violation): string => str_replace(realpath(GUARD_FIXTURES).'/', '', $violation), $violations);
}
/**
* Check (i)'s message for one class: it names no replacement, since a class no stylesheet declares
* is dead whatever it was once meant to do.
*/
function undeclared(string $class): string
{
return "class `{$class}` is declared in no stylesheet — the package declares `md-*`, the rest is your own CSS";
}
/**
* Every check that only scans a fixture, optionally names the CSS entry `missingStylesheets()`
* should check against, and compares the result to an exact violations list most of this file,
@@ -25,20 +33,58 @@ function fixtureRelative(array $violations): array
* once) stays its own test, further down.
*/
dataset('design guard scans', [
// app/Status.php returns class names from a PHP `match`, and views/page.blade.php writes one in
// running text and in a Blade comment: none of the three is a class list, so none is read.
'finds what compiles to nothing' => [
[realpath(GUARD_FIXTURES.'/app'), realpath(GUARD_FIXTURES.'/views')],
null,
[
'app/Status.php:14 Tailwind palette colour `text-red-500` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS',
'views/page.blade.php:2 Tailwind spacing utility `p-4` compiles to nothing — use `padding="space200"` (16px) on `<x-surface>`, or `var(--md-sys-measurement-space200)` in your own CSS',
'views/page.blade.php:2 '.undeclared('p-4'),
'views/page.blade.php:3 unknown Material Symbol `o-home`',
'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`',
'views/page.blade.php:5 Tailwind palette colour `text-red-600` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS',
'views/page.blade.php:6 Tailwind colour utility `text-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
'views/page.blade.php:6 '.undeclared('focus-ring'),
'views/page.blade.php:6 '.undeclared('text-tertiary'),
'views/page.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
"views/page.blade.php:8 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
'views/page.blade.php:8 '.undeclared('size-4'),
],
],
'passes a class the application\'s own stylesheet declares, and reports the one it does not' => [
[realpath(GUARD_FIXTURES.'/declared/view.blade.php'), realpath(GUARD_FIXTURES.'/declared/app.css')],
null,
['declared/view.blade.php:1 '.undeclared('mt-2')],
],
'reads a class list in an attribute, an Alpine binding and a PHP one, but not a string a condition compares or a call takes' => [
[realpath(GUARD_FIXTURES.'/declared/bindings.blade.php'), realpath(GUARD_FIXTURES.'/declared/app.css')],
null,
[
'declared/bindings.blade.php:1 '.undeclared('collapsed'),
'declared/bindings.blade.php:1 '.undeclared('dashboard-busy'),
'declared/bindings.blade.php:1 '.undeclared('is-open'),
'declared/bindings.blade.php:2 '.undeclared('dashboard-enter-start'),
'declared/bindings.blade.php:2 '.undeclared('dashboard-enter'),
'declared/bindings.blade.php:3 '.undeclared('share-list'),
'declared/bindings.blade.php:4 '.undeclared('dashboard-lead'),
'declared/bindings.blade.php:6 '.undeclared('share-list'),
],
],
'reads the keys of a class list, the array a class helper is given and every branch of a ternary, never a string that indexes an array' => [
[realpath(GUARD_FIXTURES.'/declared/lookups.blade.php'), realpath(GUARD_FIXTURES.'/declared/app.css')],
null,
[
'declared/lookups.blade.php:4 '.undeclared('product-shot-base'),
'declared/lookups.blade.php:8 '.undeclared('routes-show-run-chosen'),
'declared/lookups.blade.php:9 '.undeclared('shot-helper-one'),
'declared/lookups.blade.php:9 '.undeclared('shot-helper-two'),
'declared/lookups.blade.php:10 '.undeclared('shot-tern-a'),
'declared/lookups.blade.php:10 '.undeclared('shot-tern-b'),
'declared/lookups.blade.php:10 '.undeclared('shot-tern-c'),
],
],
'passes every class the package itself declares, showcase included' => [
realpath(GUARD_FIXTURES.'/declared/package-classes.blade.php'),
null,
[],
],
'names what a component tag takes in place of each directive' => [
realpath(GUARD_FIXTURES.'/directives'),
null,
@@ -46,309 +92,18 @@ dataset('design guard scans', [
'directives/tags.blade.php:1 Blade directive `@js` inside a component tag, where it does not compile — use `{{ \\Illuminate\\Support\\Js::from(…) }}`',
'directives/tags.blade.php:2 Blade directive `@json` inside a component tag, where it does not compile — use `{{ json_encode(…) }}`',
'directives/tags.blade.php:3 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
'directives/tags.blade.php:3 '.undeclared('is-open'),
'directives/tags.blade.php:4 Blade directive `@style` inside a component tag, where it does not compile — use `:style="\\Illuminate\\Support\\Arr::toCssStyles([…])"`',
"directives/tags.blade.php:5 Blade directive `@entangle` inside a component tag, where it does not compile — use `\$wire.entangle('…')` in the Alpine expression",
'directives/tags.blade.php:6 Blade directive `@disabled` inside a component tag, where it does not compile — use `:disabled="…"`',
'directives/tags.blade.php:7 Blade directive `@if` inside a component tag, where it does not compile — use a `:prop` binding or `{{ }}`, or move it to a plain element inside the slot',
],
],
'names the M3 breakpoint for a Tailwind prefix, with its 2.0.0 replacement' => [
realpath(GUARD_FIXTURES.'/breakpoints'),
null,
[
"breakpoints/layout.blade.php:1 Tailwind breakpoint `2xl:` compiles to nothing — M3's extra-large (1600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 1600px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `lg:` compiles to nothing — M3's expanded (840px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 840px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `md:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 600px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `sm:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 600px)` in your own CSS",
"breakpoints/layout.blade.php:1 Tailwind breakpoint `xl:` compiles to nothing — M3's large (1200px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 1200px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-2xl:` compiles to nothing — M3's extra-large (1600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 1600px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-lg:` compiles to nothing — M3's expanded (840px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 840px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-md:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 600px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-sm:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 600px)` in your own CSS",
"breakpoints/layout.blade.php:2 Tailwind breakpoint `max-xl:` compiles to nothing — M3's large (1200px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width < 1200px)` in your own CSS",
],
],
'names the 2.0.0 replacement for a pseudo-class variant, but leaves a breakpoint prefix to its own finding' => [
realpath(GUARD_FIXTURES.'/tailwind/variant.blade.php'),
null,
[
"tailwind/variant.blade.php:1 Tailwind variant class `dark:opacity-50` compiles to nothing — the roles already switch with the theme; a dark-only rule is `[data-theme='dark'] …` in your own CSS",
"tailwind/variant.blade.php:1 Tailwind variant class `hover:underline` compiles to nothing — M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS",
"tailwind/variant.blade.php:2 Tailwind breakpoint `md:` compiles to nothing — M3's medium (600px) is a layout component's `hide-below`/`hide-from`/`stack-below` prop, or `@media (width >= 600px)` in your own CSS",
"tailwind/variant.blade.php:2 Tailwind variant class `focus:md:gap-2` compiles to nothing — M3's hover, focus and press states are `md-state-layer` and `md-focus-ring` (interaction.css); any other state rule is `:hover`/`:focus-visible` in your own CSS",
'tailwind/variant.blade.php:2 Tailwind variant class `group-hover:flex` compiles to nothing — `:has()` or a descendant selector in your own CSS',
],
],
'leaves a breakpoint name that is not a variant alone' => [
__DIR__.'/../../resources/js/slider.js',
null,
[],
],
'names the M3 corner for a Tailwind radius' => [
realpath(GUARD_FIXTURES.'/scale/corners.blade.php'),
null,
[
'scale/corners.blade.php:1 value outside the M3 scale `rounded-lg` — use `var(--md-sys-shape-corner-lg)` in your own CSS, or `<x-surface corner="lg">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-md` — use `var(--md-sys-shape-corner-md)` in your own CSS, or `<x-surface corner="md">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-none` — use `var(--md-sys-shape-corner-none)` in your own CSS, or `<x-surface corner="none">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-sm` — use `var(--md-sys-shape-corner-sm)` in your own CSS, or `<x-surface corner="sm">`',
'scale/corners.blade.php:1 value outside the M3 scale `rounded-xs` — use `var(--md-sys-shape-corner-xs)` in your own CSS, or `<x-surface corner="xs">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-2xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-3xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-4xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-full` — use `var(--md-sys-shape-corner-full)` in your own CSS, or `<x-surface corner="full">`',
'scale/corners.blade.php:2 value outside the M3 scale `rounded-xl` — use `var(--md-sys-shape-corner-xl)` in your own CSS, or `<x-surface corner="xl">`',
'scale/corners.blade.php:3 value outside the M3 scale `rounded-se-2xl` — use `var(--md-sys-shape-corner-xxl)` in your own CSS, or `<x-surface corner="xxl">`',
'scale/corners.blade.php:3 value outside the M3 scale `rounded-t-lg` — use `var(--md-sys-shape-corner-lg)` in your own CSS, or `<x-surface corner="lg">`',
],
],
'names the M3 elevation level for a Tailwind shadow' => [
realpath(GUARD_FIXTURES.'/scale/elevation.blade.php'),
null,
[
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-2xs` — use `var(--md-sys-elevation-1)` in your own CSS',
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-md` — use `var(--md-sys-elevation-2)` in your own CSS',
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-sm` — use `var(--md-sys-elevation-1)` in your own CSS',
'scale/elevation.blade.php:1 value outside the M3 scale `shadow-xs` — use `var(--md-sys-elevation-1)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-2xl` — use `var(--md-sys-elevation-5)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-lg` — use `var(--md-sys-elevation-3)` in your own CSS',
'scale/elevation.blade.php:2 value outside the M3 scale `shadow-xl` — use `var(--md-sys-elevation-4)` in your own CSS',
],
],
'sends a size, a weight, a leading and a tracking to a type style' => [
realpath(GUARD_FIXTURES.'/scale/type.blade.php'),
null,
[
'scale/type.blade.php:1 value outside the M3 scale `text-2xl` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-9xl` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-base` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-lg` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-sm` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-xl` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:1 value outside the M3 scale `text-xs` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:2 value outside the M3 scale `font-extralight` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-light` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-medium` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-normal` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:2 value outside the M3 scale `font-thin` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-black` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-bold` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-extrabold` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:3 value outside the M3 scale `font-semibold` — use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
'scale/type.blade.php:4 value outside the M3 scale `leading-6` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-loose` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-none` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-normal` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-relaxed` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-snug` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:4 value outside the M3 scale `leading-tight` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-normal` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-tight` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-tighter` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-wide` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-wider` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'scale/type.blade.php:5 value outside the M3 scale `tracking-widest` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
],
],
'sends an easing and a duration to the motion tokens' => [
realpath(GUARD_FIXTURES.'/scale/motion.blade.php'),
null,
[
'scale/motion.blade.php:1 value outside the M3 scale `ease-in-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:1 value outside the M3 scale `ease-in` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:1 value outside the M3 scale `ease-linear` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:1 value outside the M3 scale `ease-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'scale/motion.blade.php:2 value outside the M3 scale `duration-1000` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
'scale/motion.blade.php:2 value outside the M3 scale `duration-300` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
'scale/motion.blade.php:2 value outside the M3 scale `duration-75` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
],
],
// scale/motion.js reads a real CSS keyword out of matchMedia() into a variable named `easing`
// (`'linear'`/`'ease-out'`), the shape the plan calls out as a false positive: nothing here is
// a class list, so the class-context match above never sees it, and the family is off
// outsideTheScale()'s line scan since the fix (see DesignGuard::outsideTheScale()'s header).
'leaves an easing or a duration word alone in a JavaScript string outside a class list' => [
realpath(GUARD_FIXTURES.'/scale/motion.js'),
null,
[],
],
'finds a colour written as a value' => [
realpath(GUARD_FIXTURES.'/colour'),
null,
[
'colour/badge.blade.php:1 arbitrary colour `bg-[#1d7afc]`, use an M3 role',
'colour/badge.blade.php:1 arbitrary colour `border-[hsl(210_80%_50%)]`, use an M3 role',
'colour/badge.blade.php:1 arbitrary colour `text-[rgb(0_0_0)]`, use an M3 role',
'colour/badge.blade.php:2 arbitrary colour `fill-[oklch(0.7_0.1_250)]`, use an M3 role',
'colour/badge.blade.php:2 arbitrary colour `ring-[color-mix(in_oklab,var(--x)_50%,transparent)]`, use an M3 role',
'colour/badge.blade.php:3 Tailwind colour utility `bg-white` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
'colour/badge.blade.php:3 Tailwind colour utility `border-white` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
'colour/badge.blade.php:3 Tailwind colour utility `text-black` compiles to nothing — M3 paints with roles, never white or black: `<x-surface level="surface-container-lowest">` or `var(--md-sys-color-surface-container-lowest)` for a white surface, an `on-` role (`md-ink`, `var(--md-sys-color-on-primary)`) for ink',
"colour/badge.blade.php:4 Tailwind colour utility `bg-on-surface/12` compiles to nothing — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-on-surface) <n>%, transparent)` in your own CSS",
"colour/badge.blade.php:4 Tailwind colour utility `border-outline/38` compiles to nothing — M3's hover, focus and press overlays are `md-state-layer`; any other tint is `color-mix(in srgb, var(--md-sys-color-outline) <n>%, transparent)` in your own CSS",
"colour/badge.blade.php:4 Tailwind colour utility `text-on-surface/60` compiles to nothing — M3's quieter text is a role, not a faded one: `md-ink-variant` or `md-ink-quiet` (text.css)",
],
],
'sends the state-layer opacities and every other role to their 2.0.0 replacement' => [
realpath(GUARD_FIXTURES.'/tailwind/inks.blade.php'),
null,
[
'tailwind/inks.blade.php:1 Tailwind colour utility `bg-background` compiles to nothing — use `<x-surface level="surface">`',
'tailwind/inks.blade.php:1 Tailwind colour utility `text-current` compiles to nothing — write `currentColor` in your own CSS',
'tailwind/inks.blade.php:1 Tailwind colour utility `text-on-primary-container` compiles to nothing — use `var(--md-sys-color-on-primary-container)` in your own CSS',
],
],
'names the md-ink-*, divider/surface or token replacement for a dead M3 role utility' => [
realpath(GUARD_FIXTURES.'/tailwind/roles.blade.php'),
null,
[
'tailwind/roles.blade.php:1 Tailwind colour utility `border-outline-variant` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility',
'tailwind/roles.blade.php:1 Tailwind colour utility `text-on-surface-variant` compiles to nothing — use `md-ink-variant` (text.css)',
'tailwind/roles.blade.php:2 Tailwind colour utility `bg-primary` compiles to nothing — use `var(--md-sys-color-primary)` in your own CSS',
'tailwind/roles.blade.php:2 Tailwind colour utility `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`',
],
],
'sends flex, grid and gap utilities to the layout component and prop that replaces each' => [
realpath(GUARD_FIXTURES.'/tailwind/layout.blade.php'),
null,
[
'tailwind/layout.blade.php:1 Tailwind layout utility `flex-col` compiles to nothing — use `<x-stack>` (`gap`, `align`)',
'tailwind/layout.blade.php:1 Tailwind layout utility `flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
'tailwind/layout.blade.php:1 Tailwind layout utility `grid` compiles to nothing — use `<x-grid>` (`:columns` per breakpoint, `gap`, `min-item`), or `<x-feed>` for a grid of cards',
'tailwind/layout.blade.php:1 Tailwind layout utility `inline-flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
"tailwind/layout.blade.php:2 Tailwind flex/grid item utility `col-span-2` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
"tailwind/layout.blade.php:2 Tailwind layout utility `grid-cols-3` compiles to nothing — use `<x-grid :columns=\"3\">`, or a per-breakpoint map (`:columns=\"['compact' => 1, 'medium' => 3]\"`)",
'tailwind/layout.blade.php:2 Tailwind layout utility `items-center` compiles to nothing — use `align="center"` on `<x-row>` or `<x-stack>`',
'tailwind/layout.blade.php:2 Tailwind layout utility `justify-between` compiles to nothing — use `justify="between"` on `<x-row>`',
'tailwind/layout.blade.php:2 Tailwind spacing utility `gap-4` compiles to nothing — use `gap="space200"` (16px) on `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
],
],
'sends padding, margin and space-between utilities to the M3 spacing step they are (gap-4 is space200)' => [
realpath(GUARD_FIXTURES.'/tailwind/spacing.blade.php'),
null,
[
"tailwind/spacing.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS",
'tailwind/spacing.blade.php:1 Tailwind spacing utility `p-4` compiles to nothing — use `padding="space200"` (16px) on `<x-surface>`, or `var(--md-sys-measurement-space200)` in your own CSS',
'tailwind/spacing.blade.php:1 Tailwind spacing utility `px-2` compiles to nothing — use `padding="space100"` (8px) on `<x-surface>`, or `var(--md-sys-measurement-space100)` in your own CSS',
'tailwind/spacing.blade.php:1 Tailwind spacing utility `space-y-4` compiles to nothing — use `<x-stack>` with `gap="space200"` (16px)',
"tailwind/spacing.blade.php:2 Tailwind spacing utility `m-4` compiles to nothing — space between siblings is a layout component's `gap=\"space200\"` (16px); any other margin is `var(--md-sys-measurement-space200)` in your own CSS",
'tailwind/spacing.blade.php:2 Tailwind spacing utility `space-x-2` compiles to nothing — use `<x-row>` with `gap="space100"` (8px)',
],
],
'sends width and height utilities to a literal length, since M3 keeps no size scale' => [
realpath(GUARD_FIXTURES.'/tailwind/sizing.blade.php'),
null,
[
"tailwind/sizing.blade.php:1 Tailwind sizing utility `h-screen` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `max-w-md` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `min-w-0` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:1 Tailwind sizing utility `w-full` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
"tailwind/sizing.blade.php:2 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale: `<x-pane width>` sets a content column's measure, `<x-icon size>` an icon's; anything else is a length in your own CSS",
],
],
'sends hidden to hide-below/hide-from and every other display utility to a plain CSS rule' => [
realpath(GUARD_FIXTURES.'/tailwind/display.blade.php'),
null,
[
'tailwind/display.blade.php:1 Tailwind display utility `block` compiles to nothing — write the `display` rule in your own CSS',
"tailwind/display.blade.php:1 Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`",
'tailwind/display.blade.php:2 Tailwind display utility `inline-block` compiles to nothing — write the `display` rule in your own CSS',
'tailwind/display.blade.php:2 Tailwind display utility `invisible` compiles to nothing — write the `display` rule in your own CSS',
],
],
'sends text-layout utilities to their md-* class' => [
realpath(GUARD_FIXTURES.'/tailwind/text.blade.php'),
null,
[
"tailwind/text.blade.php:1 Tailwind's `text-center` compiles to nothing — use `md-text-center` (text.css)",
"tailwind/text.blade.php:1 Tailwind's `truncate` compiles to nothing — use `md-truncate` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `line-clamp-2` compiles to nothing — use `md-line-clamp-2` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `sr-only` compiles to nothing — use `md-visually-hidden` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `tabular-nums` compiles to nothing — use `md-tabular` (text.css)",
"tailwind/text.blade.php:2 Tailwind's `whitespace-nowrap` compiles to nothing — use `md-nowrap` (text.css)",
],
],
'exempts a class the application\'s own CSS declares, even when it is Tailwind-shaped' => [
realpath(GUARD_FIXTURES.'/exempt'),
null,
[
"exempt/view.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS",
],
],
'reports an arbitrary [] value once, whatever utility it modifies, and strips a trailing !' => [
realpath(GUARD_FIXTURES.'/tailwind/arbitrary.blade.php'),
null,
[
'tailwind/arbitrary.blade.php:1 Tailwind arbitrary value `grid-cols-[1fr_2fr]` compiles to nothing — write the literal value in your own CSS, or use an M3 token',
'tailwind/arbitrary.blade.php:1 Tailwind arbitrary value `top-[10px]` compiles to nothing — write the literal value in your own CSS, or use an M3 token',
'tailwind/arbitrary.blade.php:2 Tailwind layout utility `flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
'tailwind/arbitrary.blade.php:4 Tailwind arbitrary property `[--material-bottom-bar:5rem]` compiles to nothing — write `--material-bottom-bar` in your own CSS',
"tailwind/arbitrary.blade.php:4 Tailwind arbitrary property `[font-variation-settings:'ROND'_100]` compiles to nothing — write `font-variation-settings` in your own CSS",
],
],
'leaves every role alone until an application forbids one' => [
realpath(GUARD_FIXTURES.'/forbidden-colours'),
null,
[
'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
],
],
'names the property a Tailwind custom-property utility stands for' => [
realpath(GUARD_FIXTURES.'/tailwind/custom-property.blade.php'),
null,
[
'tailwind/custom-property.blade.php:1 Tailwind custom-property utility `duration-(--md-sys-motion-spatial-fast-duration)` compiles to nothing — write `var(--md-sys-motion-spatial-fast-duration)` in your own CSS',
],
],
'reports position, border, effect, interactivity and text utilities with the rule to write instead' => [
realpath(GUARD_FIXTURES.'/tailwind/utilities.blade.php'),
null,
[
"tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `col-start-2` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
"tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `justify-self-end` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
"tailwind/utilities.blade.php:1 Tailwind flex/grid item utility `shrink-0` compiles to nothing — the layout components arrange their children; an item's own `flex`, `order` or `grid-column` is a rule in your own CSS",
'tailwind/utilities.blade.php:1 Tailwind inset utility `-top-2` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step',
'tailwind/utilities.blade.php:1 Tailwind inset utility `inset-0` compiles to nothing — write the offset in your own CSS, from `var(--md-sys-measurement-space*)` where it is a spacing step',
'tailwind/utilities.blade.php:1 Tailwind overflow utility `overflow-hidden` compiles to nothing — write `overflow` in your own CSS',
"tailwind/utilities.blade.php:1 Tailwind position utility `relative` compiles to nothing — write `position` in your own CSS (a FAB goes in `<x-scaffold>`'s `fab` slot)",
'tailwind/utilities.blade.php:1 Tailwind z-index utility `z-10` compiles to nothing — write `z-index` in your own CSS',
"tailwind/utilities.blade.php:1 Tailwind's `container` compiles to nothing — use `<x-pane width>`, which sets M3's margins and a measure",
'tailwind/utilities.blade.php:2 Tailwind border utility `border-t-2` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`',
'tailwind/utilities.blade.php:2 Tailwind border utility `border` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`',
'tailwind/utilities.blade.php:2 Tailwind effect utility `-rotate-45` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:2 Tailwind interactivity utility `cursor-pointer` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:2 Tailwind interactivity utility `select-none` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:2 Tailwind motion utility `transition` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`',
"tailwind/utilities.blade.php:2 Tailwind opacity utility `opacity-50` compiles to nothing — write `opacity` in your own CSS (M3's disabled content is 38 %)",
"tailwind/utilities.blade.php:2 Tailwind outline utility `outline-none` compiles to nothing — M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS",
"tailwind/utilities.blade.php:2 Tailwind outline utility `ring-2` compiles to nothing — M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS",
'tailwind/utilities.blade.php:2 Tailwind radius utility `rounded` compiles to nothing — use `var(--md-sys-shape-corner-xs)` in your own CSS, or `<x-surface corner="xs">`',
'tailwind/utilities.blade.php:2 Tailwind shadow utility `shadow` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind background utility `bg-clip-text` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind media utility `aspect-video` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind media utility `object-cover` compiles to nothing — write `aspect-ratio`/`object-fit` in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind text utility `list-disc` compiles to nothing — write the rule in your own CSS',
'tailwind/utilities.blade.php:3 Tailwind text utility `uppercase` compiles to nothing — write the rule in your own CSS',
"tailwind/utilities.blade.php:3 Tailwind's `antialiased` compiles to nothing — the foundation already smooths text in grayscale (base.css); drop it",
"tailwind/utilities.blade.php:3 Tailwind's `font-mono` compiles to nothing — put the value in `<code>`, `<kbd>` or `<samp>`, or use `md-mono` (text.css)",
"tailwind/utilities.blade.php:3 Tailwind's `font-sans` compiles to nothing — the foundation already sets the brand typeface; any other `font-family` is your own CSS",
"tailwind/utilities.blade.php:3 Tailwind's `text-end` compiles to nothing — use `md-text-end` (text.css)",
"tailwind/utilities.blade.php:3 Tailwind's `underline` compiles to nothing — `md-link` draws a link (interaction.css); any other decoration is your own CSS",
],
],
'reads class lists in Alpine, Livewire and PHP bindings, but not a string compared in a condition' => [
realpath(GUARD_FIXTURES.'/tailwind/bindings.blade.php'),
null,
[
"tailwind/bindings.blade.php:1 Tailwind opacity utility `opacity-50` compiles to nothing — write `opacity` in your own CSS (M3's disabled content is 38 %)",
"tailwind/bindings.blade.php:1 Tailwind's `hidden` compiles to nothing — use a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute (the reset keeps it hidden), or `x-show`",
'tailwind/bindings.blade.php:2 Tailwind effect utility `scale-95` compiles to nothing — write the rule in your own CSS',
'tailwind/bindings.blade.php:2 Tailwind motion utility `transition` compiles to nothing — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition` or `animation`',
'tailwind/bindings.blade.php:2 value outside the M3 scale `ease-out` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
'tailwind/bindings.blade.php:3 Tailwind spacing utility `gap-2` compiles to nothing — use `gap="space100"` (8px) on `<x-row>`, `<x-stack>`, `<x-grid>` or `<x-feed>`',
"tailwind/bindings.blade.php:4 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS",
'tailwind/bindings.blade.php:6 Tailwind layout utility `flex` compiles to nothing — use `<x-row>` (`gap`, `align`, `justify`, `wrap`, `stack-below`), or `<x-stack>` for a column',
'forbidden-colours/page.blade.php:5 '.undeclared('bg-tertiary'),
],
],
'names the missing package stylesheet and the exact @import line to add' => [
@@ -441,11 +196,6 @@ dataset('design guard scans', [
'shadows/rings.css:20 literal shadow `box-shadow: inset 0 0 4px 1px #000` — use `var(--md-sys-elevation-*)`',
],
],
'never reads a Tailwind-shaped word out of running text' => [
realpath(GUARD_FIXTURES.'/running-text.blade.php'),
null,
[],
],
]);
it('scans a fixture and reports exactly the expected violations', function (string|array $fixture, ?string $entry, array $expected) {
@@ -454,81 +204,32 @@ it('scans a fixture and reports exactly the expected violations', function (stri
expect(fixtureRelative(($entry === null ? $guard : $guard->missingStylesheets($entry))->violations()))->toBe($expected);
})->with('design guard scans');
it('reports a colour utility on the application\'s own theme colour, and a Tailwind utility of the same prefix as its own family', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/theme-colours.blade.php'))->violations());
$theme = fn (string $token, string $name, string $use = '`var(--…)`'): string => "Tailwind colour utility `{$token}` compiles to nothing — `{$name}` is neither an M3 role nor a Tailwind colour, so it named a colour of the application's own theme: use {$use} in your own CSS";
$tint = '`color-mix(in srgb, var(--…) <n>%, transparent)`';
$line = 'a line is `<x-divider>` or `<x-surface outlined>`; any other border is your own CSS, in `var(--md-sys-color-outline-variant)`';
$outline = "M3's focus indicator is `md-focus-ring` (interaction.css); any other outline is your own CSS";
expect($violations)->toBe([
'tailwind/theme-colours.blade.php:1 '.$theme('bg-off-plan', 'off-plan'),
'tailwind/theme-colours.blade.php:1 '.$theme('from-brand', 'brand'),
'tailwind/theme-colours.blade.php:1 '.$theme('text-brand-ink', 'brand-ink'),
'tailwind/theme-colours.blade.php:1 '.$theme('text-sport-run', 'sport-run'),
'tailwind/theme-colours.blade.php:1 '.$theme('to-brand-end', 'brand-end'),
'tailwind/theme-colours.blade.php:2 '.$theme('bg-route-reference/8', 'route-reference', $tint),
'tailwind/theme-colours.blade.php:2 '.$theme('border-l-zone-4', 'zone-4'),
'tailwind/theme-colours.blade.php:2 '.$theme('border-route-reference', 'route-reference'),
'tailwind/theme-colours.blade.php:2 '.$theme('ring-offset-brand/[0.4]', 'brand', $tint),
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-balance` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-clip` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-ellipsis` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-pretty` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:3 Tailwind text utility `text-wrap` compiles to nothing — write the rule in your own CSS',
"tailwind/theme-colours.blade.php:3 Tailwind's `text-nowrap` compiles to nothing — use `md-nowrap` (text.css)",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-dashed` compiles to nothing — {$line}",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-dotted` compiles to nothing — {$line}",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-none` compiles to nothing — {$line}",
"tailwind/theme-colours.blade.php:4 Tailwind border utility `border-solid` compiles to nothing — {$line}",
'tailwind/theme-colours.blade.php:4 Tailwind table utility `border-collapse` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:4 Tailwind table utility `border-separate` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:5 Tailwind gradient utility `from-0%` compiles to nothing — write the gradient in your own CSS',
'tailwind/theme-colours.blade.php:5 Tailwind gradient utility `via-none` compiles to nothing — write the gradient in your own CSS',
"tailwind/theme-colours.blade.php:5 Tailwind outline utility `outline-dashed` compiles to nothing — {$outline}",
"tailwind/theme-colours.blade.php:5 Tailwind outline utility `outline-none` compiles to nothing — {$outline}",
"tailwind/theme-colours.blade.php:5 Tailwind outline utility `ring-inset` compiles to nothing — {$outline}",
'tailwind/theme-colours.blade.php:5 Tailwind shadow utility `shadow-none` compiles to nothing — use `var(--md-sys-elevation-*)` in your own CSS',
'tailwind/theme-colours.blade.php:5 Tailwind text utility `decoration-wavy` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind SVG utility `fill-none` compiles to nothing — write `fill` or `stroke` in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-center` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-clip-text` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-cover` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-fixed` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-no-repeat` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind background utility `bg-top-left` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind colour utility `text-inherit` compiles to nothing — write `inherit` in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind interactivity utility `accent-auto` compiles to nothing — write the rule in your own CSS',
'tailwind/theme-colours.blade.php:6 Tailwind text utility `text-shadow-sm` compiles to nothing — write the rule in your own CSS',
"tailwind/theme-colours.blade.php:7 Tailwind border utility `border-bs-2` compiles to nothing — {$line}",
'tailwind/theme-colours.blade.php:7 Tailwind palette colour `bg-mauve-500` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS',
]);
});
it('leaves a Markdown mail component\'s classes and its theme to the mail theme, but still reads its icons and directives', function () {
$mail = realpath(GUARD_FIXTURES.'/mail/views');
$table = 'Tailwind display utility `table` compiles to nothing — write the `display` rule in your own CSS';
$icon = 'mail/views/vendor/mail/html/sessions.blade.php:7 unknown Material Symbol `not_a_symbol`';
$directive = 'mail/views/vendor/mail/html/sessions.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`';
config(['mail.markdown.paths' => [$mail.'/vendor/mail']]);
// The theme's `.table` exempts nothing outside the mail components, and its literals are its own.
// The theme's `.table` declares nothing outside the mail components, and its literals are its own.
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
"mail/views/page.blade.php:1 {$table}",
'mail/views/page.blade.php:1 '.undeclared('table'),
$icon,
$directive,
]);
// Outside a mail component path the same files read as the application's own: the theme's
// classes as Tailwind, its literals as check (iii)'s, and its `.table` exempting `table`.
// classes against its own rules, which declare `table` and nothing else, and its literals as
// check (iii)'s.
config(['mail.markdown.paths' => []]);
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
'mail/views/vendor/mail/html/sessions.blade.php:3 value outside the M3 scale `text-sm` — use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
'mail/views/vendor/mail/html/sessions.blade.php:4 Tailwind text utility `break-all` compiles to nothing — write the rule in your own CSS',
'mail/views/vendor/mail/html/sessions.blade.php:3 '.undeclared('sessions'),
'mail/views/vendor/mail/html/sessions.blade.php:3 '.undeclared('text-sm'),
'mail/views/vendor/mail/html/sessions.blade.php:4 '.undeclared('break-all'),
$icon,
$directive,
'mail/views/vendor/mail/html/sessions.blade.php:8 '.undeclared('wide'),
'mail/views/vendor/mail/html/themes/default.css:2 literal colour `#3d4852` — use `var(--md-sys-color-*)`',
'mail/views/vendor/mail/html/themes/default.css:3 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
]);
@@ -547,7 +248,7 @@ it('bans the roles an application leaves out, wherever 2.0.0 writes one', functi
"forbidden-colours/page.blade.php:3 `:color=\"\$failed ? 'error' : 'info'\"`: role `error` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `--md-sys-color-on-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `md-ink-error`: role `error` is not part of this application's palette",
'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
'forbidden-colours/page.blade.php:5 '.undeclared('bg-tertiary'),
"forbidden-colours/page.blade.php:7 `--md-sys-color-primary-container`: role `primary-container` is not part of this application's palette",
]);
});
@@ -576,7 +277,7 @@ it('names each stylesheet the entry imports that no scanned view needs, and leav
it('follows an entry that imports what only a Vite build resolves, reports each missing stylesheet once, and leaves the entry\'s literals to scan()', function () {
$entry = realpath(GUARD_FIXTURES.'/stylesheets/foreign-imports.css');
$mt2 = "stylesheets/views-foreign/page.blade.php:1 Tailwind spacing utility `mt-2` compiles to nothing — space between siblings is a layout component's `gap=\"space100\"` (8px); any other margin is `var(--md-sys-measurement-space100)` in your own CSS";
$mt2 = 'stylesheets/views-foreign/page.blade.php:1 '.undeclared('mt-2');
$button = "stylesheets/views-foreign/page.blade.php:2 `<x-button>` needs `components/button.css`, missing from stylesheets/foreign-imports.css — add `@import '../../../../resources/css/components/button.css';`";
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'))->missingStylesheets($entry)->violations()))
@@ -682,8 +383,7 @@ it('sorts every finding by path, then line, then message, whichever check produc
// check (iii)'s application-CSS pass always runs last, after every view's own violations —
// an unsorted result would put a.css's finding after z.blade.php's, even though "a.css"
// sorts before "z.blade.php" (Finder itself already walks views alphabetically, so this is
// the one place natural order and sorted order actually diverge). One line also names a
// palette colour and a spacing utility, from two checks that run in the opposite order.
// the one place natural order and sorted order actually diverge).
File::put($dir.'/a.css', "body {\n color: #ff0000;\n}\n");
File::put($dir.'/z.blade.php', '<div class="bg-red-500 p-4"></div>');
@@ -695,8 +395,8 @@ it('sorts every finding by path, then line, then message, whichever check produc
expect($violations)->toBe([
"{$dir}/a.css:2 literal colour `#ff0000` — use `var(--md-sys-color-*)`",
"{$dir}/z.blade.php:1 Tailwind palette colour `bg-red-500` compiles to nothing — M3 paints with roles: an `md-ink-*` class, or `var(--md-sys-color-*)` in your own CSS",
"{$dir}/z.blade.php:1 Tailwind spacing utility `p-4` compiles to nothing — use `padding=\"space200\"` (16px) on `<x-surface>`, or `var(--md-sys-measurement-space200)` in your own CSS",
"{$dir}/z.blade.php:1 ".undeclared('bg-red-500'),
"{$dir}/z.blade.php:1 ".undeclared('p-4'),
]);
});
@@ -704,33 +404,11 @@ it('passes the package\'s own views, showcase and Workbench, every stylesheet im
$root = realpath(__DIR__.'/../..');
$paths = [$root.'/resources/views', $root.'/resources/js', $root.'/src', $root.'/workbench/resources'];
// The package's own Markdown mail components are the shape `mail.markdown.paths` names: their
// classes come from mail/theme.blade.php, a stylesheet Blade renders, not from a CSS entry.
config(['mail.markdown.paths' => [$root.'/resources/views/mail']]);
expect(DesignGuard::scan($paths)->missingStylesheets($root.'/resources/css/all.css')->violations())->toBe([])
// …and the stylesheet check is not vacuous there: the showcase's plain tags do need imports.
->and(DesignGuard::scan($root.'/resources/views/showcase')->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))->violations())->not->toBe([]);
});
/**
* No Tailwind-shaped class anywhere the package ships or tests itself: DesignGuard's own family
* table (check (i)) is the standing definition of a Tailwind utility or variant, so this is that
* guard turned on the package's own source. resources/views, resources/js and src are already
* covered by the previous test's scan (workbench/resources is an application fixture and carries
* Tailwind-shaped class names on purpose, so it is left out there too); this test adds what that
* one doesn't reach: tests/Browser and tests/Feature. This file and tests/Fixtures/design-guard/
* (the guard's own fixtures) carry Tailwind on purpose and are left out.
*/
it('writes no Tailwind-shaped class anywhere under tests/', function () {
$root = realpath(__DIR__.'/../..');
$designGuardTest = realpath(__DIR__.'/DesignGuardTest.php');
$paths = collect(['tests/Browser', 'tests/Feature'])
->flatMap(fn (string $directory): Collection => collect(File::allFiles($root.'/'.$directory)))
->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'js', 'ts'], true))
->reject(fn (SplFileInfo $file): bool => (string) $file->getRealPath() === $designGuardTest)
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
->values()
->all();
expect($paths)->not->toBeEmpty();
expect(DesignGuard::scan($paths)->violations())->toBe([]);
});
+1 -1
View File
@@ -28,7 +28,7 @@ it('refers to its own components through its namespace, which no prefix or app c
// Comments document usage as an application writes it, and the showcase's examples
// are that usage; neither is compiled as the package's own markup. An example heredoc
// ends `BLADE,` as an array's value (most sections) or `BLADE;` assigned to its own
// variable (a Layout page with a single example of its own, plan step 38).
// variable (a Layout page with a single example of its own).
$source = preg_replace(['/\{\{--.*?--\}\}/s', "/<<<'BLADE'.*?^\\s*BLADE[,;]/sm"], '', $file->getContents());
preg_match_all('/<\/?x-('.$names.')(?=[\s\/>])/', $source, $matches);
+61
View File
@@ -1,5 +1,8 @@
<?php
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
@@ -61,3 +64,61 @@ it('publishes the mail components where Laravel looks for them', function () {
expect(File::files(LivewireMaterialServiceProvider::mailComponentPath().'/html'))->not->toBeEmpty()
->and(File::files(LivewireMaterialServiceProvider::mailComponentPath().'/text'))->not->toBeEmpty();
});
/**
* Pagination: the provider takes over Laravel's and Livewire's default theme when
* `livewire-material.pagination` is on (the default), so `->links()` reaches the package's views
* without either framework being told which view to render.
*/
it('sets Paginator::defaultView() to the package\'s renamed views, so a plain ->links() call renders them', function () {
$pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']);
expect(Paginator::$defaultView)->toBe('pagination::material')
->and(Paginator::$defaultSimpleView)->toBe('pagination::simple-material')
->and((string) $pages->links())->toContain('data-md-pagination');
});
it('takes over livewire.pagination_theme only while it still reads as Livewire\'s own default', function (?string $before, string $after) {
if ($before === null) {
// Livewire's own SupportPagination.php reads the key with a `'tailwind'` fallback, so a
// missing key must be treated the same as an explicit `tailwind` — this is the "missing"
// half of that. Config::offsetUnset() only sets the key to null (Repository::set()), so
// the key is removed for real by rebuilding the array without it.
config(['livewire' => Arr::except(config('livewire'), ['pagination_theme'])]);
} else {
config(['livewire.pagination_theme' => $before]);
}
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
(fn () => $this->registerPagination())->call($provider);
expect(config('livewire.pagination_theme'))->toBe($after);
})->with([
'Livewire\'s default, unset' => [null, 'material'],
'Livewire\'s default, explicit' => ['tailwind', 'material'],
'already material' => ['material', 'material'],
'another theme, left alone' => ['bootstrap', 'bootstrap'],
]);
it('changes neither the default views nor the pagination theme when the config turns pagination off', function () {
$restoreView = Paginator::$defaultView;
$restoreSimpleView = Paginator::$defaultSimpleView;
try {
config(['livewire-material.pagination' => false, 'livewire.pagination_theme' => 'tailwind']);
Paginator::defaultView('pagination::tailwind');
Paginator::defaultSimpleView('pagination::simple-tailwind');
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
(fn () => $this->registerPagination())->call($provider);
expect(config('livewire.pagination_theme'))->toBe('tailwind')
->and(Paginator::$defaultView)->toBe('pagination::tailwind')
->and(Paginator::$defaultSimpleView)->toBe('pagination::simple-tailwind');
} finally {
// Both are global statics on Laravel's own class, outliving this test's app instance —
// left as 'tailwind' they would fail the file's other pagination tests that run after.
Paginator::defaultView($restoreView);
Paginator::defaultSimpleView($restoreSimpleView);
}
});
+1 -1
View File
@@ -7,7 +7,7 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
/**
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, prebuilt into
* The showcase's own stylesheet route: `all.css` plus `showcase.css`, prebuilt into
* resources/dist/showcase.css and served without the application's Vite build, and the two package
* folders its relative `url()`s point into.
*/
+6 -26
View File
@@ -34,27 +34,6 @@ it('resolves the files an entry imports, transitively, without bundling any cont
);
});
it('resolves past what an application entry imports and only a Vite build resolves, without throwing', function () {
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
File::makeDirectory($dir);
File::put($dir.'/theme.css', '.theme {}');
File::put($dir.'/app.css', implode("\n", [
"@import 'tailwindcss';",
"@import url('https://fonts.googleapis.com/css2?family=Roboto');",
"@import './theme.css' layer(app);",
"@import './missing.css';",
"@import '".stylesheetsBundlePath('components/icon.css')."';",
]));
try {
$files = Stylesheets::resolvedFiles([$dir.'/app.css']);
} finally {
File::deleteDirectory($dir);
}
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.css']);
});
/**
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
@@ -170,11 +149,12 @@ function stylesheetsBundleLeafRules(string $css): array
*
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
* stylesheet repeats hundreds of them (the Workbench's entry did while it shared one file with
* `@tailwindcss/vite`: button.css's hover and disabled rules thirteen times), while a file's plain
* root rule alone is no evidence the minifier folds identical copies of that one together. In
* the application entry, button.css must also keep its first position: before split-button.css,
* the first file that imports it, not at the end where the application named it again.
* stylesheet repeats hundreds of them (the Workbench's entry did while `@tailwindcss/vite` was
* still in its graph, inlining imports itself ahead of Vite's own postcss-import: button.css's
* hover and disabled rules thirteen times), while a file's plain root rule alone is no evidence
* the minifier folds identical copies of that one together. In the application entry, button.css
* must also keep its first position: before split-button.css, the first file that imports it, not
* at the end where the application named it again.
*/
it('lands every rule once, at its first position, in a real Vite build of all.css and of an application entry', function () {
$node = config('livewire-material.node', 'node');
+55 -52
View File
@@ -5,14 +5,13 @@ use NoNameWeb\LivewireMaterial\Support\Layout;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
* one opens with the same layer statement, so the order holds whichever file a bundler reaches
* first; then plain imports (an `@import` cannot sit inside a layer block, and `layer()` on it
* would nest the layer twice); then its own `@layer material.<x>` blocks. The only rules outside
* a layer are foundation/hidden.css's two `!important` ones.
* The package's stylesheets are plain CSS in `material.*` layers. Every one opens with the same
* layer statement, so the order holds whichever file a bundler reaches first; then plain imports
* (an `@import` cannot sit inside a layer block, and `layer()` on it would nest the layer twice);
* then its own `@layer material.<x>` blocks. The only rules outside a layer are
* foundation/hidden.css's two `!important` ones.
*
* Every package component and layout stylesheet is plain CSS now (plan step 36), and Tailwind
* itself is gone from the package (plan step 39), so the shape, Tailwind-free and breakpoint checks
* Every package component and layout stylesheet is plain CSS, so the shape and breakpoint checks
* below run over the whole tree `all.css` reaches (foundation, layout and components together)
* rather than the foundation alone, and one file it does not reach `showcase.css`, checked on
* its own further down. A component's own values (colours as roles, shadows as elevation levels,
@@ -64,7 +63,7 @@ function stylesheetImports(string $file): array
/**
* A stylesheet and every stylesheet it reaches through `@import`, each once, in the order a
* bundler meets them. `tailwindcss` itself is a package, not a file, and is left out.
* bundler meets them. An import that is not relative (a package name, a URL) is left out.
*
* @return list<string>
*/
@@ -105,9 +104,7 @@ function foundationStylesheets(): array
/**
* Everything all.css reaches: the foundation, every layout stylesheet and every component
* stylesheet, each once. Every one of them is plain CSS (plan step 36), and Tailwind itself is
* gone from the package (plan step 39) `tailwindcss`, `tailwind.css` and its two token files no
* longer exist, so there is nothing left for this tree to exclude.
* stylesheet, each once. Every one of them is plain CSS.
*
* @return list<string>
*/
@@ -131,8 +128,7 @@ it('brings every foundation file and text.css into the foundation, and nothing e
expect($parts->diff($foundation)->values()->all())->toBe([])
->and(array_map(fn (string $import): string => basename($import), stylesheetImports(stylesheetPath('foundation.css'))))
// In the order of their layers, the rules outside every layer beside the reset — the seven
// token files imported directly (plan step 33: foundation/tokens.css held only these
// imports, and only foundation.css reached it, so it was folded in here).
// token files imported directly.
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.css', 'base.css', 'interaction.css', 'text.css'])
->and($foundation)
->toContain('tokens/scheme.css', 'tokens/shape.css', 'tokens/elevation.css', 'tokens/motion.css', 'tokens/type.css', 'tokens/state.css', 'tokens/spacing.css', 'tokens/font.css')
@@ -140,8 +136,8 @@ it('brings every foundation file and text.css into the foundation, and nothing e
});
it('paints the page in surface and on-surface, in the brand typeface, smoothed in grayscale', function () {
// An application drops Tailwind's `antialiased` with nothing to replace it: the showcase and the
// error pages, which set it themselves before, read it from here too.
// An application gets it with no class of its own needed: the showcase and the error pages,
// which set it themselves before, read it from here too.
$base = (string) preg_replace('~/\*.*?\*/~s', '', (string) file_get_contents(stylesheetPath('foundation/base.css')));
expect($base)->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);\s*color: var\(--md-sys-color-on-surface\);\s*font-family: var\(--md-ref-typeface-brand\);\s*-webkit-font-smoothing: antialiased;\s*-moz-osx-font-smoothing: grayscale;\s*\}/')
@@ -163,9 +159,9 @@ it('names the monospace faces once, as a typeface token the reset, the field and
});
it('leaves no stylesheet under resources/css/ outside all.css or showcase.css', function () {
// The full tree now that Tailwind is gone (plan step 39): every .css file on disk, not just the
// ones a bundler happens to reach — a stray or orphaned file would show up here even though
// nothing imports it, which the checks above (walking @import from all.css) cannot catch.
// The full tree: every .css file on disk, not just the ones a bundler happens to reach — a
// stray or orphaned file would show up here even though nothing imports it, which the checks
// above (walking @import from all.css) cannot catch.
$onDisk = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
@@ -257,15 +253,7 @@ it('defines x-cloak once, reached from both the foundation and all.css', functio
->and(allStylesheets())->toContain(stylesheetPath('foundation/hidden.css'));
});
it('writes no Tailwind directive anywhere all.css reaches', function () {
foreach (allStylesheets() as $file) {
expect(ComponentStylesheet::withoutComments(File::get($file)))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', stylesheetName($file))
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', stylesheetName($file));
}
});
// No Tailwind-shaped class anywhere the package ships or tests itself is
// A class no stylesheet declares, anywhere the package ships or tests itself, is
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
// either.
@@ -309,6 +297,39 @@ it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, i
}
});
it('writes every matchMedia width in resources/js at an M3 breakpoint', function () {
// The one check BreakpointsTest.php (deleted) covered that nothing else did: a script that
// widens or narrows window.matchMedia() by hand, at a width M3 does not know, rather than
// asking resources/js/breakpoints.js for the number. Isolated the same way that test isolated
// a media query's own condition — only the text inside matchMedia()'s call, up to its first
// closing quote or backtick — so a real width sitting elsewhere in the file, in a plain
// declaration rather than a query condition (a `min(40rem, 70dvh)`-style cap; none exists in
// resources/js today), is never mistaken for a breakpoint.
$files = collect(File::allFiles(__DIR__.'/../../resources/js'))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'js');
expect($files)->not->toBeEmpty();
foreach ($files as $file) {
preg_match_all('/matchMedia\(\s*[\'"`][^\'"`]*/', $file->getContents(), $conditions);
foreach ($conditions[0] as $condition) {
preg_match_all('/(\d*\.?\d+)(rem|px)/i', $condition, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
// breakpoints.js itself never writes a literal number — from() and upTo() build
// the string from the shared table (`${width(name)}px`) — so today's tree has none
// to check; this only guards whichever file writes one by hand next.
$allowed = strtolower($unit) === 'px' ? ['600', '840', '1200', '1600'] : ['37.5', '52.5', '75', '100'];
expect(in_array($number, $allowed, true))->toBeTrue(
$file->getRelativePathname().': '.trim($condition).' is not at an M3 breakpoint'
);
}
}
}
});
it('declares M3\'s spacing scale as the reference gives it', function () {
preg_match_all(
'/^\|\s*\**space(\d+)\**\s*\|\s*\**([\d.]+)×\**\s*\|\s*\**(\d+)dp/m',
@@ -526,10 +547,10 @@ it('imports, from every layout stylesheet, the stylesheet of each component its
expect($checked)->toBeGreaterThan(0);
});
// Every layout stylesheet's header, layer statement and plain-imports shape, and its freedom from
// Tailwind and from a media query off an M3 breakpoint, are covered by the two all.css-wide tests
// above (allStylesheets() reaches every layout file too) — a dedicated layout-only version of
// either would only repeat that same check on a subset already checked.
// Every layout stylesheet's header, layer statement and plain-imports shape, and its media
// queries off an M3 breakpoint, are covered by the two all.css-wide tests above (allStylesheets()
// reaches every layout file too) — a dedicated layout-only version of either would only repeat
// that same check on a subset already checked.
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
@@ -607,10 +628,6 @@ it('hides an element below and from every breakpoint but compact, and nothing el
});
it('gives every md-type class exactly font, letter-spacing and font-variation-settings', function () {
// Tailwind's tokens/utilities.css used to declare the same 30 styles as `type-*` utility
// blocks, and this test proved text.css's `.md-type-*` classes matched them; deleted with
// Tailwind (plan step 39), text.css is the only copy left, so the shape it gives each class is
// asserted directly rather than cross-checked against a second copy.
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
@@ -672,27 +689,16 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
}
});
// A test used to prove foundation/interaction.css's `.md-*` classes matched tokens/utilities.css's
// utility blocks byte for byte apart from two deliberate differences, because the components
// still written in Tailwind read the utilities while every other component read the classes.
// Deleted with Tailwind (plan step 39): every component reads the classes now, there is no second
// copy left to drift from, and interaction.css's own StylesheetsTest coverage above (the layer it
// writes into, no Tailwind directive) already holds it to the same shape every other foundation
// file keeps to.
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
it('opens the Workbench\'s one CSS entry with the layer statement, then all.css, showcase.css and the scheme', function () {
$app = ComponentStylesheet::withoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// Opens by ordering the layers, so the order holds whichever the page links first — an
// application with Tailwind of its own still opens its own entry the same way (its header).
// Opens by ordering the layers, so the order holds whichever the page links first.
expect(trim($app))->toStartWith(MATERIAL_LAYER_STATEMENT)
->toContain("@import '../../../resources/css/all.css';")
->toContain("@import '../../../resources/css/showcase.css';")
->toContain("@import './material-scheme.css';")
->not->toMatch('/@(?:tailwind|source|theme|utility|variant|custom-variant|apply|config|plugin|reference)\b/')
->and(File::get(__DIR__.'/../../vite.config.js'))
->toContain("input: ['workbench/resources/css/app.css', 'workbench/resources/js/app.js']")
->not->toContain('tailwindcss')
->and(config('livewire-material.showcase.vite'))->toBe([
'workbench/resources/css/app.css',
'workbench/resources/js/app.js',
@@ -705,10 +711,7 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($css));
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement')
->and(ComponentStylesheet::withoutComments($css))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement');
$blocks = false;
$unlayered = [];
+4 -10
View File
@@ -39,8 +39,7 @@ function typeStyles(): array
/**
* The body of every `.md-type-…` class in text.css, keyed by class name without the `md-` prefix
* (`type-display-lg`, `type-emphasized-display-lg`, ). Plain CSS now (plan step 39 deleted the
* Tailwind utilities they used to be); text.css is theirs alone.
* (`type-display-lg`, `type-emphasized-display-lg`, ). Plain CSS; text.css is theirs alone.
*
* @return array<string, string>
*/
@@ -86,14 +85,9 @@ it('ships M3\'s medium and high contrast levels beside the standard one', functi
}
});
// Tailwind's theme.css used to turn every scheme role into a `--color-*` Tailwind colour, in an
// inline theme block so it resolved on the element and a nested data-theme still repainted it
// rather than reading a value computed once on :root. Both went with Tailwind (plan step 39): the
// first fact is already the scheme test above (every role a `--md-sys-color-*` custom property,
// light and dark); the second no longer has anything to hold, because no file replaces theme.css's
// alias — every package rule reads `--md-sys-color-*` directly at its point of use, so there is no
// second custom property that could be computed once on :root and go stale under a nested
// data-theme in the first place.
// Every package rule reads a role's `--md-sys-color-*` custom property directly at its point of
// use: no second alias computed once on `:root` that a nested `data-theme` could leave stale. The
// scheme test above already proves every role is `--md-sys-color-*`, light and dark.
it('never aliases a colour role to a second custom property resolved once on :root', function () {
foreach (File::allFiles(packageCss()) as $file) {
expect($file->getContents())->not->toMatch('/--color-[\w-]+:\s*var\(--md-sys-color-/', $file->getRelativePathname());
+5 -5
View File
@@ -1,9 +1,9 @@
/*
* An application's own CSS entry, the way plan step 37's claim is about: outside the package, the
* foundation first, then the stylesheet of each component its views render. Split button, app bar,
* modal and pagination each import button.css (and icon.css, through it), and the application
* names button.css itself once more at the end. Built by tests/Fixtures/dedup.vite.config.mjs for
* StylesheetsBundleTest.php, which expects every shared rule once, at its first position.
* An application's own CSS entry: outside the package, the foundation first, then the stylesheet
* of each component its views render. Split button, app bar, modal and pagination each import
* button.css (and icon.css, through it), and the application names button.css itself once more
* at the end. Built by tests/Fixtures/dedup.vite.config.mjs for StylesheetsBundleTest.php, which
* expects every shared rule once, at its first position.
*/
@import '../../resources/css/foundation.css';
+7 -9
View File
@@ -1,16 +1,14 @@
import { defineConfig } from 'vite';
// Builds two CSS entries with no plugin in the graph, isolating Vite's own bundled postcss-import,
// whose `skipDuplicates` option is the mechanism docs/plans/material-3-alignment.md's "Tailwind's
// footprint" paragraph measured and StylesheetsBundleTest.php pins: resources/css/all.css, and
// tests/Fixtures/dedup-app.css, an application-shaped entry importing per-component stylesheets
// that share button.css. A stylesheet several files import lands once per entry, at its first
// position.
// whose `skipDuplicates` option is the deduplication StylesheetsBundleTest.php pins:
// resources/css/all.css, and tests/Fixtures/dedup-app.css, an application-shaped entry importing
// per-component stylesheets that share button.css. A stylesheet several files import lands once
// per entry, at its first position.
//
// Not the Workbench's config: `@tailwindcss/vite` inlines the imports of any entry that uses
// Tailwind itself, without that deduplication, so a shared stylesheet repeats there. The Workbench
// keeps the package's CSS in an entry of its own (workbench/resources/css/package.css) for that
// reason, until plan step 39 removes Tailwind.
// Not the Workbench's config: this one carries no plugin, which is what isolates Vite's own
// postcss-import above. The Workbench's config carries laravel-vite-plugin, so its build is not
// this dedup test's to run.
//
// DEDUP_OUT_DIR is required: the test builds into a temporary directory and deletes it, and nothing
// should land in the package's own tree, least of all workbench/public/build.
@@ -1,3 +0,0 @@
<div class="sm:flex md:grid lg:hidden xl:block 2xl:contents">
<span class="max-sm:hidden max-md:flex max-lg:grid max-xl:block max-2xl:contents"></span>
</div>
@@ -1,6 +0,0 @@
<div class="bg-[#1d7afc] text-[rgb(0_0_0)] border-[hsl(210_80%_50%)]">
<span class="fill-[oklch(0.7_0.1_250)] ring-[color-mix(in_oklab,var(--x)_50%,transparent)]"></span>
<span class="bg-white text-black border-white"></span>
<span class="text-on-surface/60 bg-on-surface/12 border-outline/38"></span>
<span class="md-ink md-type-body-md">M3's own are fine</span>
</div>
@@ -5,3 +5,7 @@
.bg-brand {
background: var(--brand);
}
.dashboard-hero {
padding: var(--md-sys-measurement-space200);
}
@@ -0,0 +1,8 @@
<div x-data="{ view: 'grid' }" :class="{ 'is-open': open, collapsed: !open, 'md-ink': view === 'grid' }" wire:loading.class="dashboard-busy"
x-transition:enter="dashboard-enter" x-transition:enter-start="dashboard-enter-start">
<ul @class(['dashboard-hero', 'share-list', 'md-ink-quiet' => $status === 'hidden', 'md-ink' => isActive('block')])
{{ $attributes->merge(['class' => 'dashboard-lead']) }}>
<li class="md-truncate
share-list">Row</li>
</ul>
</div>
@@ -0,0 +1,10 @@
{{-- Real application lines: a quoted string that is an array index ($block['base'],
$header['class'], $tab['iconClass']) reads a value, not a class list. The keys around it and
both branches of a ternary are classes, and are read. --}}
<div @class(['product-shot-base' => $block['base']])></div>
<x-sort-header :sort-by="$sortBy" :column="$header['key']" :class="$header['class'] ?? null" />
<x-icon :name="$tab['icon']" size="20" :class="$tab['iconClass']" />
<x-row :class="$band['easy'] ? 'md-type-title-sm' : 'md-type-body-md'" />
<div @class(['routes-show-run-chosen' => $referenceRunId === $run['id']])></div>
<x-badge :class="\Illuminate\Support\Arr::toCssClasses(['shot-helper-one', 'shot-helper-two' => $on])" />
<span :class="$on ? 'shot-tern-a' : ($off ? 'shot-tern-b' : 'shot-tern-c')"></span>
@@ -0,0 +1 @@
<p class="md-type-title-md md-ink-variant showcase-w-narrow">Every one of these is a package rule.</p>
@@ -1,4 +0,0 @@
{{-- Running text is not a class list: none of Tailwind's bare-word utilities should be read out
of prose just because they share a spelling with one. --}}
<p>This creates a grid of cards, in a flex layout that stays hidden below medium and stacks into a
block once the pane narrows. Nothing here truncates or grows, and the sample is not a table.</p>
@@ -1,5 +0,0 @@
<div class="rounded-none rounded-xs rounded-sm rounded-md rounded-lg">
<span class="rounded-xl rounded-2xl rounded-3xl rounded-4xl rounded-full"></span>
<span class="rounded-t-lg rounded-se-2xl"></span>
<span class="md-type-body-md" style="border-radius: var(--md-sys-shape-corner-lg)">M3's own are fine</span>
</div>
@@ -1,4 +0,0 @@
<div class="shadow-2xs shadow-xs shadow-sm shadow-md">
<span class="shadow-lg shadow-xl shadow-2xl"></span>
<span class="md-type-body-md" style="box-shadow: var(--md-sys-elevation-1)">M3's own are fine</span>
</div>
@@ -1,4 +0,0 @@
<div class="ease-linear ease-in ease-out ease-in-out">
<span class="duration-75 duration-300 duration-1000"></span>
<span class="md-type-body-md">M3's own are fine</span>
</div>
@@ -1,4 +0,0 @@
// A plain CSS keyword read out of an API, not a Tailwind class: matches DesignGuard's easing and
// duration families only in a class list, never a bare JS string like this one.
const easing = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'linear' : 'ease-out';
const duration = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 300;
@@ -1,7 +0,0 @@
<div class="text-xs text-sm text-base text-lg text-xl text-2xl text-9xl">
<span class="font-thin font-extralight font-light font-normal font-medium"></span>
<span class="font-semibold font-bold font-extrabold font-black"></span>
<span class="leading-none leading-tight leading-snug leading-normal leading-relaxed leading-loose leading-6"></span>
<span class="tracking-tighter tracking-tight tracking-normal tracking-wide tracking-wider tracking-widest"></span>
<span class="md-type-body-md md-type-title-lg md-type-emphasized-label-lg">M3's own are fine</span>
</div>
@@ -1,4 +1,4 @@
@import 'tailwindcss';
@import '@fontsource/roboto';
@import url('https://fonts.googleapis.com/css2?family=Roboto');
@import './app-theme.css' layer(app);
@import '../../../../resources/css/foundation.css';
@@ -1,4 +0,0 @@
<div class="top-[10px] grid-cols-[1fr_2fr]">
<span class="flex!"></span>
</div>
<div class="[--material-bottom-bar:5rem] [font-variation-settings:'ROND'_100]"></div>
@@ -1,8 +0,0 @@
<div x-data="{ view: 'grid' }" :class="{ 'is-open': open, hidden: !open, 'md-ink': view === 'grid' }" wire:loading.class="opacity-50"
x-transition:enter="transition ease-out" x-transition:enter-start="scale-95">
<ul @class(["gap-2", 'share-list', 'md-ink-quiet' => $status === 'hidden', 'md-ink' => isActive('block')])
{{ $attributes->merge(['class' => 'mt-2']) }}>
<li class="md-truncate
flex">Row</li>
</ul>
</div>
@@ -1 +0,0 @@
<span class="duration-(--md-sys-motion-spatial-fast-duration)"></span>
@@ -1,3 +0,0 @@
<div class="hidden block">
<span class="inline-block invisible"></span>
</div>
@@ -1 +0,0 @@
<span class="text-on-primary-container bg-background text-current md-ink-variant"></span>
@@ -1,3 +0,0 @@
<div class="flex grid inline-flex flex-col">
<span class="grid-cols-3 col-span-2 items-center justify-between gap-4"></span>
</div>
@@ -1,2 +0,0 @@
<div class="text-on-surface-variant border-outline-variant"></div>
<span class="bg-surface-container bg-primary"></span>
@@ -1,3 +0,0 @@
<div class="w-full h-screen min-w-0 max-w-md">
<span class="size-4"></span>
</div>
@@ -1,3 +0,0 @@
<div class="p-4 px-2 mt-2 space-y-4">
<span class="m-4 space-x-2"></span>
</div>
@@ -1,3 +0,0 @@
<p class="text-center truncate">
<span class="sr-only whitespace-nowrap line-clamp-2 tabular-nums"></span>
</p>
@@ -1,8 +0,0 @@
<div class="bg-off-plan text-sport-run from-brand to-brand-end text-brand-ink">
<span class="border-route-reference bg-route-reference/8 border-l-zone-4 ring-offset-brand/[0.4]"></span>
<p class="text-balance text-pretty text-wrap text-nowrap text-ellipsis text-clip"></p>
<table class="border-collapse border-separate border-solid border-dashed border-dotted border-none"></table>
<span class="shadow-none outline-none outline-dashed ring-inset decoration-wavy from-0% via-none"></span>
<span class="bg-clip-text bg-fixed bg-cover bg-center bg-no-repeat bg-top-left text-inherit text-shadow-sm fill-none accent-auto"></span>
<span class="bg-mauve-500 border-bs-2"></span>
</div>
@@ -1,4 +0,0 @@
<div class="relative inset-0 -top-2 z-10 overflow-hidden shrink-0 col-start-2 justify-self-end container">
<span class="border border-t-2 rounded shadow outline-none ring-2 transition opacity-50 -rotate-45 cursor-pointer select-none"></span>
<span class="aspect-video object-cover font-mono font-sans antialiased underline uppercase text-end list-disc table bg-clip-text"></span>
</div>
@@ -1,3 +0,0 @@
<div class="hover:underline dark:opacity-50">
<span class="group-hover:flex focus:md:gap-2"></span>
</div>
@@ -3,7 +3,7 @@
<x-icon name="o-home" />
<x-button icon="calendar_month" icon-right="not_a_symbol" label="Fine" />
<span>text-red-600</span>
<p class="text-tertiary focus-ring">Tailwind's and ours</p>
<p class="text-tertiary focus-ring">Classes no stylesheet declares</p>
<x-icon :name="$dynamic" />
<x-icon name="home" @class(['size-4']) />
</div>
+1 -1
View File
@@ -62,7 +62,7 @@ if (($timeout = (int) getenv('BROWSER_TIMEOUT')) > 0) {
/**
* The named block of resources/css/all.css the content between its `/* Heading *\/` comment and
* the next one, or the end of the file for the last block ("Navigation"). components.css and
* layout.css grouped their imports the same way before plan step 37 folded both into all.css; this
* layout.css grouped their imports the same way before all.css folded both together; this
* is the one place that block lookup lives now, so a stylesheet test asks "is this imported from
* the Containment block" without repeating the search in every file that needs it. It is here,
* rather than in tests/Feature/StylesheetsTest.php, because Pest.php is always loaded, whichever
+4 -5
View File
@@ -3,11 +3,10 @@
namespace NoNameWeb\LivewireMaterial\Tests\Support;
/**
* The classes a package view rewritten without Tailwind may write (plan step 36; the brief's
* "Interaction is the shared classes"): the three interaction classes from
* foundation/interaction.css, the fixed text classes resources/css/text.css defines (plan step 34,
* "The component views use the same set"), and a caller's own class handed on whole. Everything
* else a view draws comes from its `data-md-*` hooks.
* The classes a package view may write: the three interaction classes from
* foundation/interaction.css, the fixed text classes resources/css/text.css defines, and a
* caller's own class handed on whole. Everything else a view draws comes from its `data-md-*`
* hooks.
*
* One rule for every group's stylesheet test, so the groups cannot drift apart.
*/
@@ -19,8 +19,7 @@ class WorkbenchServiceProvider extends ServiceProvider
public function boot(): void
{
config(['livewire-material.showcase.vite' => [
// The package's whole CSS in the Workbench's one entry now that Tailwind is gone
// (plan step 39), and the JavaScript.
// The package's whole CSS in the Workbench's one entry, and the JavaScript.
'workbench/resources/css/app.css',
'workbench/resources/js/app.js',
]]);
+8 -10
View File
@@ -1,15 +1,13 @@
/* The package's CSS for the Workbench, built the way an application without Tailwind builds it
(plan step 39: Tailwind leaves the whole stack): all.css (the foundation, every layout and every
component stylesheet), showcase.css (the showcase's own chrome, so a real Vite build an error
page's fallback-free path, a browser-test probe page's `@vite()` carries it too, even though
the showcase's own pages take their CSS from the bundle route instead) and the Workbench's
scheme, in the one CSS entry the Workbench now needs. Vite's own CSS pipeline inlines the
imports here and keeps a stylesheet several components import once, at its first position the
order an application gets.
/* The package's CSS for the Workbench, built the way an application builds it: all.css (the
foundation, every layout and every component stylesheet), showcase.css (the showcase's own
chrome, so a real Vite build an error page's fallback-free path, a browser-test probe page's
`@vite()` carries it too, even though the showcase's own pages take their CSS from the bundle
route instead) and the Workbench's scheme, in the one CSS entry the Workbench needs. Vite's own
CSS pipeline inlines the imports here and keeps a stylesheet several components import once, at
its first position the order an application gets.
Opens with the same layer statement every package stylesheet does, so the `material` layers hold
their place whichever file a page links first; an application with no Tailwind of its own needs
nothing more than this. */
their place whichever file a page links first; an application needs nothing more than this. */
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import '../../../resources/css/all.css';
@import '../../../resources/css/showcase.css';