From d557dd9b49fbe225843318fac8cd1c1c5d33762c Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 13:48:59 +0200 Subject: [PATCH] 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) --- src/Testing/DesignGuard.php | 34 +++++++++++++------ tests/Feature/DesignGuardTest.php | 9 +++++ .../Fixtures/design-guard/css-lists/lists.css | 10 ++++++ 3 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 tests/Fixtures/design-guard/css-lists/lists.css diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index 881ca8e3..cd4aad61 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -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 */ - 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\(|(? (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; } /** diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index ebad9df2..ea02d0f9 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -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()); diff --git a/tests/Fixtures/design-guard/css-lists/lists.css b/tests/Fixtures/design-guard/css-lists/lists.css new file mode 100644 index 00000000..6726c164 --- /dev/null +++ b/tests/Fixtures/design-guard/css-lists/lists.css @@ -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; +}