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; +}