diff --git a/README.md b/README.md index a5f2f2bb..c5503cfd 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ it('uses only what compiles', function () { }); ``` -The guard fails on Tailwind palette colours, unknown Material Symbol names and Blade directives written inside component tags. It also fails on every Tailwind utility or variant still sitting in a view, PHP or JS file — a breakpoint prefix, a radius, shadow, type size/weight/leading/tracking, easing or duration, a flex/grid/spacing/sizing/display utility, a text-layout utility, an M3 role utility (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary `[…]` value — none of which compile any more, since the application carries no Tailwind: every line names its 2.0.0 replacement, a layout component and prop (`flex gap-4` → ``), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt, and so is every `md-*` class. +The guard fails on Tailwind palette colours, unknown Material Symbol names and Blade directives written inside component tags. `forbidColours([...])` names roles the application leaves out, and fails wherever one is still written: its `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. It also fails on every Tailwind utility or variant still sitting in a view, PHP or JS file — a breakpoint prefix, a radius, shadow, type size/weight/leading/tracking, easing or duration, a flex/grid/spacing/sizing/display utility, a text-layout utility, an M3 role utility (`text-on-surface-variant`, `bg-primary`), a pseudo-class variant, or an arbitrary `[…]` value — none of which compile any more, since the application carries no Tailwind: every line names its 2.0.0 replacement, a layout component and prop (`flex gap-4` → ``), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt, and so is every `md-*` class. `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 `` — 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. diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 17e26c82..3ede402b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -1117,7 +1117,7 @@ it('uses only what compiles', function () { }); ``` -It fails on Tailwind palette colours, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each. +It fails on Tailwind palette colours, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each. `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. It also fails on every Tailwind utility or variant, which compile to nothing in a Tailwind-free application, each with its 2.0.0 replacement — a layout component and prop, an `md-*` class, or a token for the application's own CSS. A class the application's own stylesheets declare is exempt, and so is every `md-*` class: diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index d5536e0b..27a39a77 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -239,7 +239,11 @@ class DesignGuard /** * Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container']. - * Their on-roles and containers are forbidden with them. + * 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. * * @param list $roles */ @@ -325,6 +329,10 @@ class DesignGuard $violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile"; } + foreach ($this->forbiddenRoleProps($contents) as [$line, $what]) { + $violations[] = "{$where}:{$line} {$what}"; + } + if ($this->cssEntry !== null) { foreach ($this->missingStylesheetViolations($contents, $resolved) as [$line, $what]) { $violations[] = "{$where}:{$line} {$what}"; @@ -341,7 +349,7 @@ class DesignGuard } } - foreach ($this->offTheTokens($text) as $what) { + foreach ([...$this->offTheTokens($text), ...$this->forbiddenRoles($text)] as $what) { $violations[] = "{$where}:{$line} {$what}"; } @@ -376,14 +384,77 @@ class DesignGuard protected function colourPattern(): string { - $names = [self::PALETTE]; + return '/(? + */ + protected function forbiddenRoles(string $text): array + { + $found = []; foreach ($this->forbiddenColours as $role) { - $role = preg_quote($role, '/'); - $names[] = "(?:on-)?{$role}(?:-container)?"; + $names = ['--md-sys-color-(?:on-)?'.preg_quote($role, '/').'(?:-container)?']; + + foreach ([$role, "on-{$role}", "{$role}-container", "on-{$role}-container"] as $variant) { + if (isset(self::INK_ROLE[$variant])) { + $names[] = preg_quote(self::INK_ROLE[$variant], '/'); + } + } + + preg_match_all('/(? + */ + protected function forbiddenRoleProps(string $contents): array + { + if ($this->forbiddenColours === []) { + return []; + } + + preg_match_all('/"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + + $found = []; + + foreach ($tags as $tag) { + preg_match_all('/\s(?:?)(?:color|tone)="(?[^"]*)"/', $tag[1][0], $props, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + + foreach ($props as $prop) { + $values = $prop['bound'][0] === ':' + ? (preg_match_all("/'([^']*)'/", $prop['value'][0], $strings) ? $strings[1] : []) + : [$prop['value'][0]]; + + foreach ($values as $value) { + foreach ($this->forbiddenColours as $role) { + if (preg_match('/^(?:on-)?'.preg_quote($role, '/').'(?:-container)?$/', $value) === 1) { + $found[] = [ + substr_count(substr($contents, 0, $tag[1][1] + $prop[0][1]), "\n") + 1, + '`'.trim($prop[0][0])."`: role `{$role}` is not part of this application's palette", + ]; + } + } + } + } + } + + return $found; } /** @@ -592,9 +663,16 @@ class DesignGuard $violations = []; foreach ($this->applicationStylesheets() as $file) { - $css = $this->withoutTokenFunctions($this->maskedCss((string) file_get_contents($file))); + $masked = $this->maskedCss((string) file_get_contents($file)); + $css = $this->withoutTokenFunctions($masked); $where = $this->relative($file); + foreach (explode("\n", $masked) as $index => $text) { + foreach ($this->forbiddenRoles($text) as $what) { + $violations[] = "{$where}:".($index + 1)." {$what}"; + } + } + foreach ([ ...$this->literalColours($css), ...$this->literalDeclarations($css), diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index 107f6c54..a148c9a2 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -278,12 +278,30 @@ it('reports an arbitrary [] value once, whatever utility it modifies, and strips ]); }); -it('bans the roles an application leaves out', function () { - $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views')) - ->forbidColours(['tertiary']) +it('bans the roles an application leaves out, wherever 2.0.0 writes one', function () { + $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/forbidden-colours')) + ->forbidColours(['tertiary', 'primary-container', 'error']) ->violations()); - expect($violations)->toContain('views/page.blade.php:6 colour the theme does not declare `text-tertiary`'); + expect($violations)->toBe([ + '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:1 `color=\"tertiary\"`: role `tertiary` is not part of this application's palette", + "forbidden-colours/page.blade.php:2 `:tone=\"'tertiary'\"`: role `tertiary` is not part of this application's palette", + "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:7 `--md-sys-color-primary-container`: role `primary-container` is not part of this application's palette", + "forbidden-colours/app.css:3 `--md-sys-color-tertiary-container`: role `tertiary` is not part of this application's palette", + "forbidden-colours/app.css:4 `--md-sys-color-on-primary-container`: role `primary-container` is not part of this application's palette", + ]); +}); + +it('leaves every role alone until an application forbids one', function () { + $violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/forbidden-colours'))->violations()); + + expect($violations)->toBe([ + 'forbidden-colours/page.blade.php:5 Tailwind colour utility `bg-tertiary` compiles to nothing — use `var(--md-sys-color-tertiary)` in your own CSS', + ]); }); it('bans any further pattern', function () { diff --git a/tests/Fixtures/design-guard/forbidden-colours/app.css b/tests/Fixtures/design-guard/forbidden-colours/app.css new file mode 100644 index 00000000..f979c929 --- /dev/null +++ b/tests/Fixtures/design-guard/forbidden-colours/app.css @@ -0,0 +1,9 @@ +/* var(--md-sys-color-tertiary) in a comment is ignored */ +.hero { + background: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-primary-container); +} + +.fine { + color: var(--md-sys-color-primary); +} diff --git a/tests/Fixtures/design-guard/forbidden-colours/page.blade.php b/tests/Fixtures/design-guard/forbidden-colours/page.blade.php new file mode 100644 index 00000000..f889b1c8 --- /dev/null +++ b/tests/Fixtures/design-guard/forbidden-colours/page.blade.php @@ -0,0 +1,8 @@ + + + +

Failed

+
+ + +A fixed role is another role