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:
co-authored by
Claude Opus 5
parent
c0c71ef16c
commit
2073480da4
+64
-49
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user