Accept a list of token values in the guard, and quote what was written

A transition-duration or -timing-function listing one token per property
(a spatial spring for scale, an effects spring for opacity) was reported
as literal: blanking each var() left a bare comma, which is not one of
the safe values. Every item of a list is now checked on its own. A
message also quoted the blanked shell, `var(      ), 200ms`; it now
quotes the declaration as written. SealShare's drop zone found it
(plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 13:48:59 +02:00
co-authored by Claude Opus 5
parent f307079a45
commit d557dd9b49
3 changed files with 42 additions and 11 deletions
+23 -11
View File
@@ -859,7 +859,7 @@ class DesignGuard
foreach ([
...$this->literalColours($css),
...$this->literalDeclarations($css),
...$this->literalDeclarations($css, $masked),
...$this->offScaleMediaQueries($css),
] as [$line, $what]) {
$violations[] = "{$where}:{$line} {$what}";
@@ -913,23 +913,26 @@ 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). A
* custom property is never one of them, whatever its name says.
* custom property is never one of them, whatever its name says. `$css` has its token functions
* blanked; a message quotes the declaration as written in `$source`, the same CSS before that
* (blanking keeps every offset).
*
* @return list<array{0: int, 1: string}>
*/
protected function literalDeclarations(string $css): array
protected function literalDeclarations(string $css, ?string $source = null): array
{
$found = [];
foreach ($this->declarations($css) as [$property, $value, $offset]) {
$value = trim($value);
foreach ($this->declarations($css) as [$property, $blanked, $offset]) {
$value = trim($blanked);
$written = trim(substr($source ?? $css, $offset, strlen($blanked)));
$line = substr_count(substr($css, 0, $offset), "\n") + 1;
$isRing = $property === 'box-shadow' && $this->isSafeBoxShadowRing($value);
if (isset(self::LITERAL_PROPERTIES[$property]) && ! $this->isSafeLiteralValue($value) && ! $isRing) {
$config = self::LITERAL_PROPERTIES[$property];
$found[] = [$line, "literal {$config['kind']} `{$property}: {$value}` — {$config['hint']}"];
$found[] = [$line, "literal {$config['kind']} `{$property}: {$written}` — {$config['hint']}"];
}
if (! in_array($property, ['transition', 'animation'], true)) {
@@ -941,13 +944,13 @@ class DesignGuard
// unlike `ease`/`ease-in`/`ease-out`/`ease-in-out`, a spring's own shape and never
// written by hand, or `cubic-bezier()`/`steps()`, always a hand-rolled curve.
if (preg_match('/cubic-bezier\(|steps\(|(?<![\w-])(?:ease(?:-in-out|-in|-out)?|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`"];
$found[] = [$line, "literal easing in `{$property}: {$written}` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`"];
}
preg_match_all('/(?<![\w.-])(\d*\.?\d+)m?s(?![\w-])/i', $value, $durations);
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"];
$found[] = [$line, "literal duration in `{$property}: {$written}` — use `var(--md-sys-motion-…-duration)`, paired with its easing"];
}
}
@@ -957,13 +960,22 @@ class DesignGuard
/**
* 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.
* parentheses still standing — is exactly as fine as one masked away entirely. A list of values
* (`transition-duration: var(…), var(…)`) is fine when every item in it is.
*/
protected function isSafeLiteralValue(string $value): bool
{
$value = trim((string) preg_replace('/\b(?:var|calc|min|max|clamp)\(\s*\)/i', '', $value));
$value = (string) preg_replace('/\b(?:var|calc|min|max|clamp)\(\s*\)/i', '', $value);
return $value === '' || in_array(strtolower($value), ['0', '0px', '0s', '0ms', 'none', 'inherit', 'initial', 'unset', 'revert', 'normal', 'auto'], true);
foreach (explode(',', $value) as $item) {
$item = trim($item);
if ($item !== '' && ! in_array(strtolower($item), ['0', '0px', '0s', '0ms', 'none', 'inherit', 'initial', 'unset', 'revert', 'normal', 'auto'], true)) {
return false;
}
}
return true;
}
/**
+9
View File
@@ -553,6 +553,15 @@ it('finds every literal design value and off-scale media query in the applicatio
]);
});
it('accepts a list of values when every item is a token, and quotes a mixed one as written', function () {
// SealShare's drop zone pairs a spatial spring for scale with an effects spring for opacity in one
// transition-duration list; blanking both var()s used to leave a bare comma the guard called literal.
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/css-lists/lists.css'))->violations()))->toBe([
'css-lists/lists.css:8 literal duration `transition-duration: var(--md-sys-motion-spatial-slow-duration), 200ms` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
'css-lists/lists.css:9 literal easing `transition-timing-function: var(--md-sys-motion-spatial-slow), ease-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
]);
});
it('leaves a box-shadow ring in a colour role alone, but still reports a blurred or literal-coloured one', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/shadows/rings.css'))->violations());
@@ -0,0 +1,10 @@
/* A list of values, each from a token, is fine; one literal in the list is not. */
.fine-list {
transition-duration: var(--md-sys-motion-spatial-slow-duration), var(--md-sys-motion-effects-slow-duration);
transition-timing-function: var(--md-sys-motion-spatial-slow), var(--md-sys-motion-effects-slow);
}
.mixed-list {
transition-duration: var(--md-sys-motion-spatial-slow-duration), 200ms;
transition-timing-function: var(--md-sys-motion-spatial-slow), ease-out;
}