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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 08:48:25 +02:00
co-authored by Claude Opus 5
parent c0c71ef16c
commit 2073480da4
3 changed files with 90 additions and 53 deletions
+64 -49
View File
@@ -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<array{0: int, 1: string}>
*/
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('/(?<property>'.$properties.')\s*:\s*(?<value>[^;{}]+);/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\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|linear|step-start|step-end)(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal easing in `{$property}: {$value}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
foreach (['transition', 'animation'] as $shorthand) {
preg_match_all('/(?<![\w-])'.$shorthand.'\s*:\s*(?<value>[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE);
preg_match_all('/(?<![\w.-])(\d*\.?\d+)m?s(?![\w-])/i', $value, $durations);
foreach ($shorthandMatches['value'] as [$value, $offset]) {
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
if (preg_match('/cubic-bezier\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|linear)(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal easing in `{$shorthand}: {$value}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
if (preg_match('/(?<![\w.-])\d+(?:\.\d+)?m?s(?![\w-])/i', $value) === 1) {
$found[] = [$line, "literal duration in `{$shorthand}: {$value}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
}
if (array_filter($durations[1], fn (string $number): bool => (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<array{0: int, 1: string}>
*/
@@ -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('/(?<![\w-])(?:min-|max-)?(?:device-)?width(?![\w-])/i', $feature) !== 1) {
continue;
}
$line = substr_count(substr($css, 0, $preludeOffset + $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)"];
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/i', $feature, $widths, PREG_OFFSET_CAPTURE);
foreach ($widths[0] as $i => [$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 = '/(?<![\w-])'.$name.'\(/i';
$offset = 0;
@@ -1039,6 +1047,13 @@ class DesignGuard
}
$inner = substr($css, $open + 1, $i - $open - 2);
if ($withVar && preg_match('/(?<![\w-])var\(/i', $inner) !== 1) {
$offset = $open + 1;
continue;
}
$blank = (string) preg_replace('/[^\n]/', ' ', $inner);
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
$offset = $i;
+10 -4
View File
@@ -510,13 +510,19 @@ it('finds every literal design value and off-scale media query in the applicatio
'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',
'app-css/app.css:8 literal font weight `font-weight: 600` — use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`',
'app-css/app.css:9 literal line height `line-height: 1.4` — use `var(--md-sys-typescale-*)`, which sets it with the size',
'app-css/app.css:7 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
'app-css/app.css:8 literal font weight `font-weight: 600` — use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`',
'app-css/app.css:9 literal line height `line-height: 1.4` — set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class',
'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:34 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:43 literal radius `border-top-left-radius: calc(8px + 4px)` — use `var(--md-sys-shape-corner-*)`',
'app-css/app.css:46 literal font `font: 600 14px/20px sans-serif` — use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class',
'app-css/app.css:47 literal font size `font-size: clamp(1rem, 2vw, 2rem)` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
'app-css/app.css:48 literal line height `line-height: 1.5` — set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class',
"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) with `>=` 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 `<`",
]);
});
@@ -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; } }