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
|
* (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,
|
* 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
|
* 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
|
* than 600/840/1200/1600px — each with its token or breakpoint. A `var()`, or a `calc()`,
|
||||||
* `var(--md-sys-…)` or `calc()` is never flagged, whatever it contains.
|
* `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
|
* (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
|
* 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
|
* 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-start-radius' => ['kind' => 'radius', 'hint' => 'use `var(--md-sys-shape-corner-*)`'],
|
||||||
'border-end-end-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-*)`'],
|
'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' => ['kind' => 'font', 'hint' => 'use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class'],
|
||||||
'font-weight' => ['kind' => 'font weight', 'hint' => 'use `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`'],
|
'font-size' => ['kind' => 'font size', 'hint' => 'set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class'],
|
||||||
'line-height' => ['kind' => 'line height', 'hint' => 'use `var(--md-sys-typescale-*)`, which sets it with the size'],
|
'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)`'],
|
'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`'],
|
'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`'],
|
'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`
|
* 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
|
* 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}>
|
* @return list<array{0: int, 1: string}>
|
||||||
*/
|
*/
|
||||||
protected function literalDeclarations(string $css): array
|
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 = [];
|
$found = [];
|
||||||
|
|
||||||
foreach ($matches as $match) {
|
foreach ($this->declarations($css) as [$property, $value, $offset]) {
|
||||||
$property = strtolower($match['property'][0]);
|
$value = trim($value);
|
||||||
$value = trim($match['value'][0]);
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$line = substr_count(substr($css, 0, $match[0][1]), "\n") + 1;
|
if (preg_match('/cubic-bezier\(|steps\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|linear|step-start|step-end)(?![\w-])/i', $value) === 1) {
|
||||||
$config = self::LITERAL_PROPERTIES[$property];
|
$found[] = [$line, "literal easing in `{$property}: {$value}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
|
||||||
$found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"];
|
}
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['transition', 'animation'] as $shorthand) {
|
preg_match_all('/(?<![\w.-])(\d*\.?\d+)m?s(?![\w-])/i', $value, $durations);
|
||||||
preg_match_all('/(?<![\w-])'.$shorthand.'\s*:\s*(?<value>[^;{}]+);/i', $css, $shorthandMatches, PREG_OFFSET_CAPTURE);
|
|
||||||
|
|
||||||
foreach ($shorthandMatches['value'] as [$value, $offset]) {
|
if (array_filter($durations[1], fn (string $number): bool => (float) $number > 0) !== []) {
|
||||||
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
|
$found[] = [$line, "literal duration in `{$property}: {$value}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
|
||||||
|
|
||||||
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"];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,19 +917,22 @@ class DesignGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A value `withoutTokenFunctions()` left with nothing but the empty shell of a `var()`/`calc()`
|
* A value `withoutTokenFunctions()` left with nothing but the empty shell of a token function
|
||||||
* call — whitespace where the call's own argument list used to be, the call's own name and
|
* — 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.
|
* parentheses still standing — is exactly as fine as one masked away entirely.
|
||||||
*/
|
*/
|
||||||
protected function isSafeLiteralValue(string $value): bool
|
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}>
|
* @return list<array{0: int, 1: string}>
|
||||||
*/
|
*/
|
||||||
@@ -947,18 +943,25 @@ class DesignGuard
|
|||||||
$found = [];
|
$found = [];
|
||||||
|
|
||||||
foreach ($matches[1] as [$prelude, $preludeOffset]) {
|
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]) {
|
foreach ($features[1] as [$feature, $featureOffset]) {
|
||||||
$number = (float) $widths[1][$i][0];
|
if (preg_match('/(?<![\w-])(?:min-|max-)?(?:device-)?width(?![\w-])/i', $feature) !== 1) {
|
||||||
$unit = strtolower($widths[2][$i][0]);
|
|
||||||
|
|
||||||
if ($unit === 'px' && in_array((int) $number, [600, 840, 1200, 1600], true) && $number == (int) $number) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$line = substr_count(substr($css, 0, $preludeOffset + $widthOffset), "\n") + 1;
|
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/i', $feature, $widths, PREG_OFFSET_CAPTURE);
|
||||||
$found[] = [$line, "media query width `{$whole}` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large)"];
|
|
||||||
|
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
|
* `$css` with the argument list of every `var(…)` call, and of every `calc()`, `min()`,
|
||||||
* parentheses tracked, so a `calc(var(--x) + 4px)` inside a `var()` fallback stays hidden
|
* `max()` or `clamp()` built on one, blanked out (nested parentheses tracked) — what lets
|
||||||
* too) — what lets check (iii) treat any value built from a token as fine, whatever literal
|
* check (iii) treat any value built from a token as fine, whatever literal numbers or colours
|
||||||
* numbers or colours it wraps.
|
* it wraps, a `var()` fallback included.
|
||||||
*/
|
*/
|
||||||
protected function withoutTokenFunctions(string $css): string
|
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';
|
$pattern = '/(?<![\w-])'.$name.'\(/i';
|
||||||
$offset = 0;
|
$offset = 0;
|
||||||
@@ -1039,6 +1047,13 @@ class DesignGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
$inner = substr($css, $open + 1, $i - $open - 2);
|
$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);
|
$blank = (string) preg_replace('/[^\n]/', ' ', $inner);
|
||||||
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
|
$css = substr($css, 0, $open + 1).$blank.substr($css, $i - 1);
|
||||||
$offset = $i;
|
$offset = $i;
|
||||||
|
|||||||
@@ -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: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: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: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: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 `var(--md-sys-typescale-*)` or `var(--md-sys-typescale-emphasized-*)`',
|
'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` — use `var(--md-sys-typescale-*)`, which sets it with the size',
|
'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: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 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: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;
|
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; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user