Retire forbidAbsolutes() and forbidOpacityInk() from the design guard
Plan step 41 review (the user, 2026-09-15): without Tailwind, bg-white and text-on-surface/60 compile to nothing like any other utility, so family (i) now reports every colour utility on an M3 role, a 1.x ink name (text-meta, border-divider), white, black or current, with or without an opacity, each with its 2.0.0 replacement (an md-ink-* class, <x-divider>, <x-surface level>, md-state-layer or the role's var()). Check (iii) reads named colours anywhere in a declaration's value (border: 1px solid white), hex codes only in values (an id like #add no longer matches), and masks strings and url() contents first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
c047fefd8b
commit
6dc996afdf
@@ -154,8 +154,6 @@ The guard fails on Tailwind palette colours, unknown Material Symbol names and B
|
||||
|
||||
`missingStylesheets($cssEntry)` checks the CSS entry's `@import` graph (followed through every package file's own imports) against the package tags a view actually renders — unprefixed, under the configured prefix, or `<x-livewire-material::…>` — and `->links()`; each missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths), which reports a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or off-scale media query with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine, and the generated `material-scheme.css` is skipped.
|
||||
|
||||
Two checks are opt-in, and matter mainly to an application still migrating off Tailwind: `forbidAbsolutes()` also fails on `bg-white` and `text-black` (M3's white is `surface-container-lowest`), and `forbidOpacityInk()` on opacity used as emphasis (`text-on-surface/60` → `text-on-surface-variant` or `text-outline`). M3 reserves 38 % on content and 12 % on a container for the disabled state, which is what the package's own components use them for, so neither is on by default.
|
||||
|
||||
## AI agents
|
||||
|
||||
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's two guidelines — the library's own, and `material-3`, a page of M3's rules an agent reads in every session — and two skills: `livewire-material-development` (every component, prop, slot and trap) and `material-3-design` (M3's colour roles, surfaces, elevation, shape, type, motion, states, window size classes and accessibility, with the library's utility beside each M3 name and Google's source page for each chapter).
|
||||
|
||||
@@ -1113,8 +1113,6 @@ it('uses only what compiles', function () {
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
->missingStylesheets(resource_path('css/app.css'))
|
||||
->forbidColours(['tertiary']) // roles this application's rules leave out
|
||||
->forbidAbsolutes() // opt-in, migration only: `bg-white`, `text-black`
|
||||
->forbidOpacityInk() // opt-in, migration only: `text-on-surface/60`
|
||||
->violations())->toBe([]);
|
||||
});
|
||||
```
|
||||
@@ -1132,10 +1130,12 @@ It also fails on every Tailwind utility or variant, which compile to nothing in
|
||||
| `hidden` | a layout component's `hide-below`/`hide-from` prop, or `[hidden]` |
|
||||
| `block`, `inline-block`, `invisible` | a plain `display` rule in your own CSS |
|
||||
| `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` |
|
||||
| `text-on-surface-variant`, `text-primary` … | `md-ink-variant`, `md-ink-primary` … |
|
||||
| `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … |
|
||||
| `border-outline-variant` | `<x-divider>` or `<x-surface outlined>` |
|
||||
| `bg-surface-container` | `<x-surface level="surface-container">` |
|
||||
| `bg-primary`, `border-error` … | `var(--md-sys-color-*)` |
|
||||
| `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black |
|
||||
| `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay |
|
||||
| `rounded-lg`, `rounded-t-2xl`, `rounded-full` | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` |
|
||||
| `shadow-sm`, `shadow-md` … `shadow-2xl` | `var(--md-sys-elevation-1)` … `var(--md-sys-elevation-5)` |
|
||||
| `text-sm`, `leading-6`, `tracking-wide` | one of the `md-type-*` classes, which set size, line height and tracking together |
|
||||
@@ -1146,8 +1146,6 @@ It also fails on every Tailwind utility or variant, which compile to nothing in
|
||||
|
||||
`missingStylesheets($cssEntry)` follows the entry's `@import` graph through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too) and checks it against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; a missing one names the exact `@import` line to add, and a tag the application shadows with its own component of the same name is reported instead — the application's component wins in Blade. It also turns on a check of the application's own CSS (the entry and what it imports outside the package, plus any `.css` file among the scanned paths, `material-scheme.css` excluded): a literal colour, radius, shadow, font size, weight, line height, letter spacing, easing, duration or a media query off M3's 600/840/1200/1600px scale, each with its token or breakpoint — a value inside `var(--md-sys-…)` or `calc()` is always fine.
|
||||
|
||||
`forbidAbsolutes()` adds `white` and `black` (M3's white is `surface-container-lowest`, its ink an `on-` role) and `forbidOpacityInk()` adds opacity as emphasis (`text-on-surface/60` → `text-on-surface-variant` or `text-outline`). Both are off by default and matter mainly to an application still migrating off Tailwind: M3 reserves 38 % on content and 12 % on a container for the disabled state, and the package's own components are written with those two opacities.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||||
|
||||
+101
-104
@@ -35,13 +35,10 @@ use Symfony\Component\Finder\Finder;
|
||||
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
|
||||
* (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()`, `forbidAbsolutes()`,
|
||||
* `forbidOpacityInk()` and `forbid()`. These, and the breakpoint,
|
||||
* scale and colour-value checks, read a line at a time, so a class assembled at runtime
|
||||
* (`'text-'.$tone`) or hidden in a comment stays invisible — the same reason to write
|
||||
* class names out whole. `forbidAbsolutes()` and `forbidOpacityInk()` matter mainly to an
|
||||
* application still migrating off Tailwind, where the package's own opacity-based
|
||||
* disabled state is still the only legitimate use of either.
|
||||
* 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.
|
||||
*
|
||||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
* ->missingStylesheets(resource_path('css/app.css'))
|
||||
@@ -69,12 +66,6 @@ class DesignGuard
|
||||
/** 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"\']*\]?/';
|
||||
|
||||
/** The two absolutes. M3's white is a surface role, not a colour. */
|
||||
protected const string ABSOLUTE_COLOUR = '/(?<![\w-])'.self::UTILITY.'-(?:white|black)(?![\w-])/';
|
||||
|
||||
/** Opacity used as emphasis on ink, which M3 expresses as a role. */
|
||||
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
|
||||
|
||||
/**
|
||||
* Tailwind's breakpoint prefixes and the M3 window size class each names. Tailwind's
|
||||
* 640/768/1024/1280/1536 are 40–88 px from M3's 600/840/1200/1600, so a prefix maps to the
|
||||
@@ -156,7 +147,29 @@ class DesignGuard
|
||||
'tabular-nums' => 'md-tabular',
|
||||
];
|
||||
|
||||
/** An ink role Tailwind's `text-*` utility named, and the `md-ink-*` class that replaces it. */
|
||||
/**
|
||||
* A colour utility written on an M3 role, a 1.x ink name, 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 = '/^(?<utility>bg|text|border(?:-[trblxyse])?|divide|ring(?:-offset)?|outline|fill|stroke|decoration|accent|caret|placeholder|shadow|from|via|to)-'
|
||||
.'(?<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|body|meta|quiet|structure|chrome|divider|white|black|current|transparent)'
|
||||
.'(?:\/(?<opacity>\d{1,3}|\[[^\]]*\]))?$/';
|
||||
|
||||
/** 1.x's semantic ink names, and the M3 role each resolved to (UPGRADE.md § 4). */
|
||||
protected const array INK_ALIASES = [
|
||||
'body' => 'on-surface-variant',
|
||||
'meta' => 'on-surface-variant',
|
||||
'quiet' => 'outline',
|
||||
'structure' => 'outline-variant',
|
||||
'chrome' => 'outline-variant',
|
||||
'divider' => 'outline-variant',
|
||||
];
|
||||
|
||||
/** An ink role, and the `md-ink-*` class (text.css) that sets it on plain text. */
|
||||
protected const array INK_ROLE = [
|
||||
'on-surface' => 'md-ink',
|
||||
'on-surface-variant' => 'md-ink-variant',
|
||||
@@ -169,6 +182,12 @@ 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';
|
||||
|
||||
@@ -200,10 +219,6 @@ class DesignGuard
|
||||
/** @var list<string> */
|
||||
protected array $forbiddenColours = [];
|
||||
|
||||
protected bool $forbiddenAbsolutes = false;
|
||||
|
||||
protected bool $forbiddenOpacityInk = false;
|
||||
|
||||
protected ?string $cssEntry = null;
|
||||
|
||||
/** @var array<string, true>|null */
|
||||
@@ -235,35 +250,6 @@ class DesignGuard
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Also ban the two absolutes. M3 paints with roles only: the white of a light page is
|
||||
* `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled
|
||||
* button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet
|
||||
* sometimes does mean the absolute — and because, until an application finishes leaving
|
||||
* Tailwind, `bg-white`/`text-black` may still be sitting in an untouched view.
|
||||
*/
|
||||
public function forbidAbsolutes(): static
|
||||
{
|
||||
$this->forbiddenAbsolutes = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Also ban opacity as emphasis on ink: M3 says secondary text is a role — on-surface-variant
|
||||
* for supporting text, outline for the quietest — never a faded on-surface. It reserves two
|
||||
* opacities for the disabled state, 38 % on content and 12 % on a container, and the
|
||||
* package's own disabled styles are written with those two, which is why this is off by
|
||||
* default; an application that draws its disabled states from the components does not need
|
||||
* either opacity of its own.
|
||||
*/
|
||||
public function forbidOpacityInk(): static
|
||||
{
|
||||
$this->forbiddenOpacityInk = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any further pattern, matched line by line.
|
||||
*/
|
||||
@@ -491,38 +477,15 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours written as a value rather than a role: an arbitrary one always, the two absolutes
|
||||
* and opacity on ink when the application asks for them.
|
||||
* Colours written as a value rather than a role: an arbitrary hex, function or mix.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function colourValues(string $text): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
|
||||
|
||||
foreach ($matches[0] as $class) {
|
||||
$found[] = "arbitrary colour `{$class}`, use an M3 role";
|
||||
}
|
||||
|
||||
if ($this->forbiddenAbsolutes) {
|
||||
preg_match_all(self::ABSOLUTE_COLOUR, $text, $matches);
|
||||
|
||||
foreach ($matches[0] as $class) {
|
||||
$found[] = "absolute colour `{$class}`, use an M3 role";
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->forbiddenOpacityInk) {
|
||||
preg_match_all(self::OPACITY_INK, $text, $matches);
|
||||
|
||||
foreach ($matches[0] as $class) {
|
||||
$found[] = "opacity on ink `{$class}`, use a role (`text-on-surface-variant`, `text-outline`)";
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
return array_map(fn (string $class): string => "arbitrary colour `{$class}`, use an M3 role", $matches[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,23 +548,35 @@ class DesignGuard
|
||||
return "Tailwind's `{$token}` compiles to nothing — use `".self::TEXT_LAYOUT_UTILITY[$token].'` (text.css)';
|
||||
}
|
||||
|
||||
if (preg_match('/^text-(?<role>on-surface(?:-variant)?|outline|primary|error|success|warning|info|inverse-on-surface)$/', $token, $m) === 1) {
|
||||
return "Tailwind ink role `{$token}` compiles to nothing — use `".self::INK_ROLE[$m['role']].'` (text.css)';
|
||||
return $this->colourUtilityHint($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (preg_match('/^(?:border(?:-[trblxyse])?|divide|ring)-outline(?:-variant)?$/', $token) === 1) {
|
||||
return "Tailwind line role `{$token}` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility";
|
||||
}
|
||||
$utility = $m['utility'];
|
||||
$role = self::INK_ALIASES[$m['role']] ?? $m['role'];
|
||||
$dead = "Tailwind colour utility `{$token}` compiles to nothing";
|
||||
|
||||
if (preg_match('/^bg-(?<role>surface(?:-dim|-bright|-container(?:-lowest|-low|-high|-highest)?)?)$/', $token, $m) === 1) {
|
||||
return "Tailwind surface role `{$token}` compiles to nothing — use `<x-surface level=\"{$m['role']}\">`";
|
||||
}
|
||||
|
||||
if (preg_match('/^(?:bg|border(?:-[trblxyse])?|ring|fill|stroke|divide|decoration|accent|caret|outline)-(?:on-)?(?:primary|secondary|tertiary|error|success|warning|info)(?:-container)?$/', $token) === 1) {
|
||||
return "Tailwind role utility `{$token}` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS";
|
||||
}
|
||||
|
||||
return null;
|
||||
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'], true) => "{$dead} — write `".($role === 'current' ? 'currentColor' : 'transparent').'` 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])?|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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -617,7 +592,7 @@ class DesignGuard
|
||||
$violations = [];
|
||||
|
||||
foreach ($this->applicationStylesheets() as $file) {
|
||||
$css = $this->withoutTokenFunctions($this->withoutCssComments((string) file_get_contents($file)));
|
||||
$css = $this->withoutTokenFunctions($this->maskedCss((string) file_get_contents($file)));
|
||||
$where = $this->relative($file);
|
||||
|
||||
foreach ([
|
||||
@@ -633,25 +608,43 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal colour in `$css`: a hex code, a colour function, or a named colour other than
|
||||
* `transparent`/`currentColor` written where a value is expected (after `:`, `,` or `(`) —
|
||||
* which is what keeps a selector like `.red-banner` from matching.
|
||||
* Every literal colour in a declaration's value: a hex code, a colour function, or a named
|
||||
* colour other than `transparent`/`currentColor`, anywhere in the value (`border: 1px solid
|
||||
* white` as much as `color: white`). Only values are read, so a selector like `.red-banner` or
|
||||
* an id like `#add` never matches.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
protected function literalColours(string $css): array
|
||||
{
|
||||
$pattern = '/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab|color)\([^)]*\)|(?<=[:,(]\s{0,20})(?:'.self::CSS_NAMED_COLOURS.')\b(?!-))/i';
|
||||
$found = [];
|
||||
|
||||
preg_match_all($pattern, $css, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($this->declarations($css) as [, $value, $offset]) {
|
||||
preg_match_all('/(?<![\w#-])(?:#[0-9a-fA-F]{3,8}(?![\w-])|(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)|(?:'.self::CSS_NAMED_COLOURS.')(?![\w-]))/i', $value, $matches, PREG_OFFSET_CAPTURE);
|
||||
|
||||
return array_map(
|
||||
fn (array $match): array => [
|
||||
substr_count(substr($css, 0, $match[1]), "\n") + 1,
|
||||
"literal colour `{$match[0]}` — use `var(--md-sys-color-*)`",
|
||||
],
|
||||
$matches[0],
|
||||
);
|
||||
foreach ($matches[0] as [$colour, $at]) {
|
||||
$found[] = [
|
||||
substr_count(substr($css, 0, $offset + $at), "\n") + 1,
|
||||
"literal colour `{$colour}` — use `var(--md-sys-color-*)`",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every declaration in `$css` — a property, custom properties included, and its value up to
|
||||
* the `;` or `}` that ends it — with the value's byte offset. A selector (`a:hover {`) or an
|
||||
* at-rule condition (`@media (prefers-color-scheme: dark) {`) ends in `{` and is never one.
|
||||
*
|
||||
* @return list<array{0: string, 1: string, 2: int}>
|
||||
*/
|
||||
protected function declarations(string $css): array
|
||||
{
|
||||
preg_match_all('/(?<![\w-])(?<property>--[\w-]+|-?[a-zA-Z][\w-]*)\s*:\s*(?<value>[^;{}]*)(?=[;}])/', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
return array_map(fn (array $match): array => [strtolower($match['property'][0]), $match['value'][0], $match['value'][1]], $matches);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -769,13 +762,17 @@ class DesignGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* `$css` with every `/* … *\/` comment blanked out, line breaks kept.
|
||||
* `$css` with every `/* … *\/` comment, the inside of every quoted string and of every
|
||||
* unquoted `url(…)` blanked out, line breaks kept — so neither a class check nor a value check
|
||||
* reads `content: ".flex"`, a font name or a data URI's `fill='white'`.
|
||||
*/
|
||||
protected function withoutCssComments(string $css): string
|
||||
protected function maskedCss(string $css): string
|
||||
{
|
||||
return (string) preg_replace_callback(
|
||||
'/\/\*.*?\*\//s',
|
||||
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
|
||||
'/\/\*.*?\*\/|"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'|(?<=url\()[^)\'"]*(?=\))/is',
|
||||
fn (array $match): string => str_starts_with($match[0], '/*')
|
||||
? str_repeat("\n", substr_count($match[0], "\n"))
|
||||
: (string) preg_replace('/[^\n]/', ' ', $match[0]),
|
||||
$css,
|
||||
);
|
||||
}
|
||||
@@ -1097,7 +1094,7 @@ class DesignGuard
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->applicationStylesheets() as $file) {
|
||||
$css = $this->withoutCssComments((string) file_get_contents($file));
|
||||
$css = $this->maskedCss((string) file_get_contents($file));
|
||||
|
||||
preg_match_all('/(?<![\w.#-])\.(-?[a-zA-Z_][\w-]*)/', $css, $matches);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ it('finds what compiles to nothing', function () {
|
||||
expect(fixtureRelative($violations))->toBe([
|
||||
'app/Status.php:14 colour the theme does not declare `text-red-500`',
|
||||
"views/page.blade.php:2 Tailwind spacing utility `p-4` compiles to nothing — use a layout component's `gap`/`padding` prop (`<x-stack gap>`, `<x-surface padding>`), or `var(--md-sys-measurement-space*)` in your own CSS",
|
||||
'views/page.blade.php:6 Tailwind colour utility `text-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS',
|
||||
'views/page.blade.php:8 Tailwind sizing utility `size-4` compiles to nothing — M3 keeps no size scale; write the literal length in your own CSS, or use `<x-pane width>`/`<x-grid min-item>` where it fits',
|
||||
'views/page.blade.php:3 unknown Material Symbol `o-home`',
|
||||
'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`',
|
||||
@@ -150,6 +151,12 @@ it('sends an easing and a duration to the motion tokens', function () {
|
||||
|
||||
it('finds a colour written as a value', function () {
|
||||
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations()))->toBe([
|
||||
'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 `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: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: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)",
|
||||
"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:1 arbitrary colour `bg-[#1d7afc]`, use an M3 role',
|
||||
'colour/badge.blade.php:1 arbitrary colour `text-[rgb(0_0_0)]`, use an M3 role',
|
||||
'colour/badge.blade.php:1 arbitrary colour `border-[hsl(210_80%_50%)]`, use an M3 role',
|
||||
@@ -158,14 +165,27 @@ it('finds a colour written as a value', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends 1.x ink names, the state-layer opacities and every other role to their 2.0.0 replacement', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/inks.blade.php'))->violations());
|
||||
|
||||
expect($violations)->toBe([
|
||||
'tailwind/inks.blade.php:1 Tailwind colour utility `text-meta` compiles to nothing — use `md-ink-variant` (text.css)',
|
||||
'tailwind/inks.blade.php:1 Tailwind colour utility `text-quiet` compiles to nothing — use `md-ink-quiet` (text.css)',
|
||||
'tailwind/inks.blade.php:1 Tailwind colour utility `border-divider` compiles to nothing — a line is `<x-divider>` or `<x-surface outlined>`, not a border utility',
|
||||
'tailwind/inks.blade.php:2 Tailwind colour utility `text-on-primary-container` compiles to nothing — use `var(--md-sys-color-on-primary-container)` in your own CSS',
|
||||
'tailwind/inks.blade.php:2 Tailwind colour utility `bg-background` compiles to nothing — use `<x-surface level="surface">`',
|
||||
'tailwind/inks.blade.php:2 Tailwind colour utility `text-current` compiles to nothing — write `currentColor` in your own CSS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the md-ink-*, divider/surface or token replacement for a dead M3 role utility', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/tailwind/roles.blade.php'))->violations());
|
||||
|
||||
expect($violations)->toBe([
|
||||
'tailwind/roles.blade.php:1 Tailwind ink role `text-on-surface-variant` compiles to nothing — use `md-ink-variant` (text.css)',
|
||||
'tailwind/roles.blade.php:1 Tailwind line role `border-outline-variant` compiles to nothing — use `<x-divider>` or `<x-surface outlined>` for a line, not a border utility',
|
||||
'tailwind/roles.blade.php:2 Tailwind surface role `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`',
|
||||
'tailwind/roles.blade.php:2 Tailwind role utility `bg-primary` compiles to nothing — use `var(--md-sys-color-*)` in your own CSS',
|
||||
'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: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:2 Tailwind colour utility `bg-surface-container` compiles to nothing — use `<x-surface level="surface-container">`',
|
||||
'tailwind/roles.blade.php:2 Tailwind colour utility `bg-primary` compiles to nothing — use `var(--md-sys-color-primary)` in your own CSS',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -258,38 +278,6 @@ it('reports an arbitrary [] value once, whatever utility it modifies, and strips
|
||||
]);
|
||||
});
|
||||
|
||||
it('bans the two absolutes when asked', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidAbsolutes()->violations());
|
||||
|
||||
expect($violations)->toContain(
|
||||
'colour/badge.blade.php:3 absolute colour `bg-white`, use an M3 role',
|
||||
'colour/badge.blade.php:3 absolute colour `text-black`, use an M3 role',
|
||||
'colour/badge.blade.php:3 absolute colour `border-white`, use an M3 role',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the two absolutes alone by default', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations());
|
||||
|
||||
expect($violations)->not->toContain('colour/badge.blade.php:3 absolute colour `bg-white`, use an M3 role');
|
||||
});
|
||||
|
||||
it('bans opacity as emphasis on ink when asked', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->forbidOpacityInk()->violations());
|
||||
|
||||
expect($violations)->toContain(
|
||||
'colour/badge.blade.php:4 opacity on ink `text-on-surface/60`, use a role (`text-on-surface-variant`, `text-outline`)',
|
||||
'colour/badge.blade.php:4 opacity on ink `bg-on-surface/12`, use a role (`text-on-surface-variant`, `text-outline`)',
|
||||
'colour/badge.blade.php:4 opacity on ink `border-outline/38`, use a role (`text-on-surface-variant`, `text-outline`)',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves opacity on ink alone by default, as the package\'s own disabled styles use it', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations());
|
||||
|
||||
expect($violations)->not->toContain('colour/badge.blade.php:4 opacity on ink `text-on-surface/60`, use a role (`text-on-surface-variant`, `text-outline`)');
|
||||
});
|
||||
|
||||
it('bans the roles an application leaves out', function () {
|
||||
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views'))
|
||||
->forbidColours(['tertiary'])
|
||||
@@ -414,6 +402,8 @@ it('finds every literal design value and off-scale media query in the applicatio
|
||||
'app-css/app.css:3 literal colour `#ff0000` — use `var(--md-sys-color-*)`',
|
||||
'app-css/app.css:4 literal colour `rgb(0, 0, 0)` — use `var(--md-sys-color-*)`',
|
||||
'app-css/app.css:6 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`',
|
||||
'app-css/app.css:24 literal colour `white` — use `var(--md-sys-color-*)`',
|
||||
'app-css/app.css:25 literal colour `#fff` — use `var(--md-sys-color-*)`',
|
||||
'app-css/app.css:5 literal radius `border-radius: 12px` — use `var(--md-sys-shape-corner-*)`',
|
||||
'app-css/app.css:6 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`',
|
||||
'app-css/app.css:7 literal font size `font-size: 14px` — use `var(--md-sys-typescale-*)`, which sets size, line height and weight together',
|
||||
@@ -422,7 +412,7 @@ it('finds every literal design value and off-scale media query in the applicatio
|
||||
'app-css/app.css:10 literal letter spacing `letter-spacing: 0.02em` — use `var(--md-sys-typescale-*-tracking)`',
|
||||
'app-css/app.css:11 literal easing in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
|
||||
'app-css/app.css:11 literal duration in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
|
||||
"app-css/app.css:21 media query width `700px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)",
|
||||
"app-css/app.css:34 media query width `700px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* white in a comment is ignored, and so is #fff */
|
||||
.quiet::before {
|
||||
content: "white";
|
||||
border: 1px solid white;
|
||||
outline-color: #fff;
|
||||
background: url(data:image/svg+xml;utf8,<svg fill='black'></svg>);
|
||||
}
|
||||
|
||||
#add-button, .red-banner {
|
||||
color: transparent;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.card {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<p class="text-meta text-quiet border-divider"></p>
|
||||
<span class="text-on-primary-container bg-background text-current md-ink-variant"></span>
|
||||
Reference in New Issue
Block a user