From 2073480da4d8530086f734267abc344e8a5dacc5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 08:48:25 +0200 Subject: [PATCH] Tighten the design guard's application-CSS check on real stylesheets Plan step 41 review, check (iii): calc() hid every literal inside it and now only one built on a var() does (min(), max() and clamp() too), so calc(8px + 4px) and clamp(1rem, 2vw, 2rem) are reported. Declarations are read whole, so a last one without a semicolon is seen and a custom property named --card-border-radius is not mistaken for border-radius; the font shorthand is checked; a 0s duration is fine. Media queries read only width features: a height or prefers-* condition is not a breakpoint, and (600px <= width < 840px) passes. The type hints no longer suggest font-size: var(--md-sys-typescale-*), which is a font shorthand value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- src/Testing/DesignGuard.php | 113 +++++++++++--------- tests/Feature/DesignGuardTest.php | 14 ++- tests/Fixtures/design-guard/app-css/app.css | 16 +++ 3 files changed, 90 insertions(+), 53 deletions(-) diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index 616fa8a0..fb55d098 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -33,8 +33,8 @@ use Symfony\Component\Finder\Finder; * (iii) every `.css` file among the scanned paths, outside the package and excluding the * generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight, * 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 value inside - * `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains. + * 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 @@ -231,9 +231,10 @@ class DesignGuard 'border-end-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'border-end-end-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'], 'box-shadow' => ['kind' => 'shadow', 'hint' => 'use `var(--md-sys-elevation-*)`'], - 'font-size' => ['kind' => 'font size', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets size, line height and weight together'], - 'font-weight' => ['kind' => 'font weight', 'hint' => 'use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`'], - 'line-height' => ['kind' => 'line height', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets it with the size'], + 'font' => ['kind' => 'font', 'hint' => 'use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class'], + 'font-size' => ['kind' => 'font size', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class'], + 'font-weight' => ['kind' => 'font weight', 'hint' => 'use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`'], + 'line-height' => ['kind' => 'line height', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class'], 'letter-spacing' => ['kind' => 'letter spacing', 'hint' => 'use `var(--md-sys-typescale-*-tracking)`'], 'transition-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'], 'animation-timing-function' => ['kind' => 'easing', 'hint' => 'use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`'], @@ -879,44 +880,36 @@ class DesignGuard /** * Every literal value on a property check (iii) knows, plus a `transition`/`animation` * shorthand's embedded easing or duration (its property name alone cannot say which part of - * the value is which, so both are searched for whenever either is written literally). + * the value is which, so both are searched for whenever either is written literally). A + * custom property is never one of them, whatever its name says. * * @return list */ protected function literalDeclarations(string $css): array { - $properties = implode('|', array_map(fn (string $p): string => preg_quote($p, '/'), array_keys(self::LITERAL_PROPERTIES))); - - preg_match_all('/(?'.$properties.')\s*:\s*(?[^;{}]+);/i', $css, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); - $found = []; - foreach ($matches as $match) { - $property = strtolower($match['property'][0]); - $value = trim($match['value'][0]); + foreach ($this->declarations($css) as [$property, $value, $offset]) { + $value = trim($value); + $line = substr_count(substr($css, 0, $offset), "\n") + 1; - if ($this->isSafeLiteralValue($value)) { + if (isset(self::LITERAL_PROPERTIES[$property]) && ! $this->isSafeLiteralValue($value)) { + $config = self::LITERAL_PROPERTIES[$property]; + $found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"]; + } + + if (! in_array($property, ['transition', 'animation'], true)) { continue; } - $line = substr_count(substr($css, 0, $match[0][1]), "\n") + 1; - $config = self::LITERAL_PROPERTIES[$property]; - $found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"]; - } + if (preg_match('/cubic-bezier\(|steps\(|(?[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE); + preg_match_all('/(? (float) $number > 0) !== []) { + $found[] = [$line, "literal duration in `{$property}: {$value}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"]; } } @@ -924,19 +917,22 @@ class DesignGuard } /** - * A value `withoutTokenFunctions()` left with nothing but the empty shell of a `var()`/`calc()` - * call — whitespace where the call's own argument list used to be, the call's own name and + * A value `withoutTokenFunctions()` left with nothing but the empty shell of a token function + * — whitespace where the call's own argument list used to be, the call's own name and * parentheses still standing — is exactly as fine as one masked away entirely. */ protected function isSafeLiteralValue(string $value): bool { - $value = trim((string) preg_replace('/\b(?:var|calc)\(\s*\)/i', '', $value)); + $value = trim((string) preg_replace('/\b(?:var|calc|min|max|clamp)\(\s*\)/i', '', $value)); - return $value === '' || in_array(strtolower($value), ['0', '0px', '0s', 'none', 'inherit', 'initial', 'unset', 'normal', 'auto'], true); + return $value === '' || in_array(strtolower($value), ['0', '0px', '0s', '0ms', 'none', 'inherit', 'initial', 'unset', 'revert', 'normal', 'auto'], true); } /** - * Every `@media` width in `$css` that is not one of M3's four breakpoints written in px. + * Every width in an `@media` condition that is not one of M3's four breakpoints written in + * px: `(width >= 840px)`, `(min-width: 840px)` and `(600px <= width < 840px)` pass, `(width > + * 839px)`, `(max-width: 839.98px)` and anything in `rem`/`em` do not. A height, a + * `prefers-*` feature and an `@container` query are not breakpoints and are not read. * * @return list */ @@ -947,18 +943,25 @@ class DesignGuard $found = []; foreach ($matches[1] as [$prelude, $preludeOffset]) { - preg_match_all('/(\d+(?:\.\d+)?)(px|rem|em)\b/i', $prelude, $widths, PREG_OFFSET_CAPTURE); + preg_match_all('/\(([^()]*)\)/', $prelude, $features, PREG_OFFSET_CAPTURE); - foreach ($widths[0] as $i => [$whole, $widthOffset]) { - $number = (float) $widths[1][$i][0]; - $unit = strtolower($widths[2][$i][0]); - - if ($unit === 'px' && in_array((int) $number, [600, 840, 1200, 1600], true) && $number == (int) $number) { + foreach ($features[1] as [$feature, $featureOffset]) { + if (preg_match('/(? [$whole, $widthOffset]) { + $number = (float) $widths[1][$i][0]; + + if (strtolower($widths[2][$i][0]) === 'px' && in_array($number, [600.0, 840.0, 1200.0, 1600.0], true)) { + continue; + } + + $line = substr_count(substr($css, 0, $preludeOffset + $featureOffset + $widthOffset), "\n") + 1; + $found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`"]; + } } } @@ -1007,17 +1010,22 @@ class DesignGuard } /** - * `$css` with the argument list of every `var(…)` and `calc(…)` call blanked out (nested - * parentheses tracked, so a `calc(var(--x) + 4px)` inside a `var()` fallback stays hidden - * too) — what lets check (iii) treat any value built from a token as fine, whatever literal - * numbers or colours it wraps. + * `$css` with the argument list of every `var(…)` call, and of every `calc()`, `min()`, + * `max()` or `clamp()` built on one, blanked out (nested parentheses tracked) — what lets + * check (iii) treat any value built from a token as fine, whatever literal numbers or colours + * it wraps, a `var()` fallback included. */ protected function withoutTokenFunctions(string $css): string { - return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), 'calc'); + return $this->maskFunctionCalls($this->maskFunctionCalls($css, 'var'), '(?:calc|min|max|clamp)', true); } - protected function maskFunctionCalls(string $css, string $name): string + /** + * `$css` with the argument list of every `$name(…)` call blanked out — or, with `$withVar`, + * of only those whose arguments use a `var()`, so `calc(var(--x) + 4px)` is a token's value + * and `calc(12px + 2px)` or `clamp(1rem, 2vw, 2rem)` stays a literal one. + */ + protected function maskFunctionCalls(string $css, string $name, bool $withVar = false): string { $pattern = '/(?=` or `<`", + "app-css/app.css:51 media query width `839px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`", + "app-css/app.css:53 media query width `40rem` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`", ]); }); diff --git a/tests/Fixtures/design-guard/app-css/app.css b/tests/Fixtures/design-guard/app-css/app.css index 2fd68024..65dc1b7c 100644 --- a/tests/Fixtures/design-guard/app-css/app.css +++ b/tests/Fixtures/design-guard/app-css/app.css @@ -36,3 +36,19 @@ display: flex; } } + +.edges { + --card-border-radius: 12px; + border-radius: min(var(--md-sys-shape-corner-lg), 50%); + border-top-left-radius: calc(8px + 4px); + transition: opacity 0s; + animation: pulse var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow) infinite; + font: 600 14px/20px sans-serif; + font-size: clamp(1rem, 2vw, 2rem); + line-height: 1.5 +} + +@media (width > 839px) and (max-height: 500px) { .edges { display: none; } } +@media (600px <= width < 840px), (prefers-reduced-motion: reduce) { .edges { display: block; } } +@media (max-width: 40rem) { .edges { display: grid; } } +@container (width > 400px) { .edges { display: flex; } }