Match easing and duration Tailwind classes only in a class list

outsideTheScale() read ease-in/ease-out/ease-in-out/duration-* line by line
across every file, the way it still reads its other scale steps — but a
plain CSS keyword read out of an API into a JS string (matchMedia(…) ?
'linear' : 'ease-out') names a real value, not a Tailwind class, and the
same line scan read the words back out of a stylesheet test's own regex
literals (`ease-in`, `ease-out`) as if they were Tailwind too. Tested both
ways per the plan's instruction: Blade/PHP class lists have the identical
false-positive shape, so the two families move to FAMILIES, matched only
through a class list (literalClasses()/tailwindFamilyHint()) as the newer
families already are, in Blade and PHP as much as in JavaScript.

Plan step 42 (Phase F), Part B.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 09:31:01 +02:00
co-authored by Claude Sonnet 5
parent 9cfb24f419
commit b243222e26
3 changed files with 25 additions and 6 deletions
+11 -6
View File
@@ -133,6 +133,14 @@ class DesignGuard
['/^shadow-elevation-(?<level>[0-5])$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-elevation-{level})` in your own CSS'],
['/^ease-(?<easing>(?:spatial|effects)-(?:fast|default|slow))$/', '1.x utility `%s` compiles to nothing — pair `var(--md-sys-motion-{easing})` with `var(--md-sys-motion-{easing}-duration)` in your own `transition`'],
['/^ease-(?<easing>standard|emphasized)(?<phase>-accelerate|-decelerate)?$/', '1.x utility `%s` compiles to nothing — use `var(--md-sys-motion-easing-{easing}{phase})` in your own `transition`'],
// Tailwind's cleared easing and duration scale: matched only here, in a class list a
// Blade or PHP file writes literally (never line-by-line across every file, the way
// outsideTheScale() still reads its other scale steps) — a plain PHP or JS string such as
// `matchMedia(…) ? 'linear' : 'ease-out'` names a real CSS keyword, not a Tailwind class,
// and reading every line for the bare word flagged both that and the word appearing inside
// a stylesheet test's own regex literal (`ease-in`, `ease-out`) as if it were one.
['/^ease-(?:in-out|linear|in|out)$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`'],
['/^duration-\d+$/', 'value outside the M3 scale `%s` — pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`'],
['/^[a-z][a-z-]*-\((?<property>--[\w-]+)\)$/', 'Tailwind custom-property utility `%s` compiles to nothing — write `var({property})` in your own CSS'],
['/^(?:static|fixed|absolute|relative|sticky)$/', "Tailwind position utility `%s` compiles to nothing — write `position` in your own CSS (a FAB goes in `<x-scaffold>`'s `fab` slot)"],
@@ -566,8 +574,9 @@ class DesignGuard
}
/**
* The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities
* Tailwind shipped and M3's own scales replace.
* The radius, shadow, type-size, weight, leading and tracking utilities Tailwind shipped and
* M3's own scales replace. The easing and duration steps of the same Tailwind scale are not
* read here — see the class header's note by the `ease-*`/`duration-*` entries of `FAMILIES`.
*
* @return list<string>
*/
@@ -581,8 +590,6 @@ class DesignGuard
.'|font-(?<weight>thin|extralight|light|normal|medium|semibold|bold|extrabold|black)'
.'|leading-(?<leading>none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)'
.'|tracking-(?<tracking>tighter|tight|normal|wider|widest|wide)'
.'|ease-(?<easing>in-out|linear|in|out)'
.'|duration-(?<duration>\d+)'
.')(?![\w-])/';
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
@@ -606,8 +613,6 @@ class DesignGuard
isset($match['corner']) => 'use `var(--md-sys-shape-corner-'.self::CORNERS[$match['corner']].')` in your own CSS, or `<x-surface corner="'.self::CORNERS[$match['corner']].'">`',
isset($match['elevation']) => 'use `var(--md-sys-elevation-'.self::ELEVATIONS[$match['elevation']].')` in your own CSS',
isset($match['weight']) => 'use one of the `md-type-emphasized-*` classes (text.css), or a `md-type-*` size already at the right weight',
isset($match['easing']) => 'pair `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)` with its `-duration` in your own `transition`',
isset($match['duration']) => 'pair `var(--md-sys-motion-…-duration)` with its easing in your own `transition`',
default => 'use one of the `md-type-*` classes (text.css), which set size, line height and tracking together',
};
}
+10
View File
@@ -150,6 +150,16 @@ it('sends an easing and a duration to the motion tokens', function () {
]);
});
it('leaves an easing or a duration word alone in a JavaScript string outside a class list', function () {
// scale/motion.js reads a real CSS keyword out of matchMedia() into a variable named `easing`
// (`'linear'`/`'ease-out'`), the shape the plan calls out as a false positive: nothing here
// is a class list, so the class-context match above never sees it, and the family is off
// outsideTheScale()'s line scan since the fix (see DesignGuard::outsideTheScale()'s header).
$violations = DesignGuard::scan(realpath(GUARD_FIXTURES.'/scale/motion.js'))->violations();
expect($violations)->toBe([]);
});
it('finds a colour written as a value', function () {
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/colour'))->violations()))->toBe([
'colour/badge.blade.php:1 arbitrary colour `bg-[#1d7afc]`, use an M3 role',
@@ -0,0 +1,4 @@
// A plain CSS keyword read out of an API, not a Tailwind class: matches DesignGuard's easing and
// duration families only in a class list, never a bare JS string like this one.
const easing = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'linear' : 'ease-out';
const duration = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 300;