tailwind-a11y 0.13.0 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,16 +26,28 @@ const NON_COLOR_SCALE_NAMES = new Set(["opacity", "linear", "conic"]);
26
26
  export function lastColorToken(className, prefix) {
27
27
  let found = null;
28
28
  for (const raw of className.split(/\s+/).filter(Boolean)) {
29
- const base = raw.slice(raw.lastIndexOf(":") + 1); // strip hover:/dark:/md: variants
30
- if (!base.startsWith(`${prefix}-`))
29
+ // Variant-scoped classes (hover:/dark:/md:/...) are skipped entirely,
30
+ // not stripped down to their base utility -- fixed after independent
31
+ // testing found a real false negative: `bg-white dark:bg-gray-900`
32
+ // with `text-gray-300` silently passed, because stripping the `dark:`
33
+ // prefix let it participate in last-token-wins as if it were the real,
34
+ // always-rendered resting-state background, when `dark:bg-gray-900`
35
+ // only ever applies under a completely different condition. Mirrors
36
+ // `extractTouchTargets.ts`'s `lastSizeToken`, which already excludes
37
+ // any variant-scoped size token from resting-state resolution the same
38
+ // way (`if (raw.includes(":")) continue;`) -- this had no equivalent
39
+ // guard for colors.
40
+ if (raw.includes(":"))
31
41
  continue;
32
- const rest = base.slice(prefix.length + 1);
42
+ if (!raw.startsWith(`${prefix}-`))
43
+ continue;
44
+ const rest = raw.slice(prefix.length + 1);
33
45
  if (!COLOR_TOKEN.test(rest))
34
46
  continue;
35
47
  const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
36
48
  if (scaleName && NON_COLOR_SCALE_NAMES.has(scaleName))
37
49
  continue;
38
- found = base;
50
+ found = raw;
39
51
  }
40
52
  return found;
41
53
  }
@@ -131,6 +131,24 @@ export function suggestContrastFix(textClass, bgClass, required, palette = defau
131
131
  }
132
132
  return null;
133
133
  }
134
+ // resolveColorValue() bails out on any bg-side opacity modifier before ever
135
+ // checking whether the underlying color is real (background-side opacity
136
+ // compositing is out of scope -- see CLAUDE.md -- since it depends on
137
+ // knowing what's rendered behind an already-semi-transparent background).
138
+ // Caught in independent adversarial testing: this made checkContrastValueSkips
139
+ // report `bg-gray-800/50 is not a recognized color`, even though gray-800
140
+ // is a perfectly recognized default-palette color -- a developer reading
141
+ // that message would reasonably (and pointlessly) try adding a theme entry
142
+ // for it. Distinguishes the two cases by re-resolving the color with the
143
+ // opacity suffix stripped off: if that succeeds, the real reason is the
144
+ // out-of-scope opacity, not an unrecognized color.
145
+ function bgSkipReason(bgColorClass, palette) {
146
+ const { base, alpha } = splitOpacityModifier(bgColorClass);
147
+ if (alpha < 1 && resolveColorValue(base, palette) !== null) {
148
+ return `${bgColorClass} is a recognized color, but background-side opacity isn't resolved (compositing it correctly requires knowing what's rendered behind it) — skipped`;
149
+ }
150
+ return `${bgColorClass} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`;
151
+ }
134
152
  // A candidate that extractChecks *did* find a background for, but whose
135
153
  // text or bg utility didn't resolve to a known value (custom theme color,
136
154
  // non-hex arbitrary value, background-side opacity shorthand) — surfaced
@@ -150,11 +168,7 @@ export function checkContrastValueSkips(checks, palette = defaultPalette) {
150
168
  const bgHex = resolveColorValue(check.bgColorClass, palette);
151
169
  const bgRgb = bgHex ? hexToRgb(bgHex) : null;
152
170
  if (!bgRgb) {
153
- skips.push({
154
- file: check.file,
155
- line: check.line,
156
- reason: `${check.bgColorClass} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`,
157
- });
171
+ skips.push({ file: check.file, line: check.line, reason: bgSkipReason(check.bgColorClass, palette) });
158
172
  continue;
159
173
  }
160
174
  const { alpha } = splitOpacityModifier(check.textColorClass);
@@ -11,9 +11,15 @@ const REMOVAL_BASE = "outline-none";
11
11
  // resets every element to `border: 0 solid`, so border-width stays 0
12
12
  // regardless of style and no border is ever drawn, same failure mode as
13
13
  // border-0.
14
+ // inset-shadow-none/inset-ring-0 (Tailwind v4's inset-* box-shadow family,
15
+ // missed by the original one-time audit -- these utilities didn't exist,
16
+ // or weren't considered, at the time) are the exact same failure mode as
17
+ // shadow-none/ring-0: verified against a real build that inset-shadow-none
18
+ // computes to a fully transparent shadow and inset-ring-0 computes to a
19
+ // 0px-wide inset ring, both real but invisible.
14
20
  const DEGENERATE_BASES = new Set([
15
21
  "outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent",
16
- "border-none", "border-hidden",
22
+ "border-none", "border-hidden", "inset-shadow-none", "inset-ring-0",
17
23
  ]);
18
24
  // Modifier-only utilities (opacity/offset/inset) don't set a concrete value
19
25
  // on their own — e.g. bg-opacity-50 with no bg-* color, or ring-offset-4
@@ -58,16 +64,35 @@ const NON_VISUAL_PATTERN = /^bg-blend-|^border-spacing(-[xy])?-/;
58
64
  // offset cases above, but shaped like a color token rather than a fixed
59
65
  // suffix, so it reuses COLOR_TOKEN (the exact same "is this a color value"
60
66
  // test extractClasses.ts uses) instead of a third, drifting definition of
61
- // what a color looks like.
67
+ // what a color looks like. inset-shadow-{color}/inset-ring-{color} are the
68
+ // same mechanism one prefix-family over (verified against a real build:
69
+ // inset-shadow-blue-500 alone only sets --tw-inset-shadow-color,
70
+ // inset-ring-blue-500 alone only sets --tw-inset-ring-color -- neither
71
+ // produces a box-shadow without a companion size utility).
62
72
  function isColorOnlyShadowOrRing(base) {
63
- const shadowMatch = /^shadow-(.+)$/.exec(base);
73
+ const shadowMatch = /^(?:inset-)?shadow-(.+)$/.exec(base);
64
74
  if (shadowMatch && COLOR_TOKEN.test(shadowMatch[1]))
65
75
  return true;
66
- const ringMatch = /^ring-(.+)$/.exec(base);
76
+ const ringMatch = /^(?:inset-)?ring-(.+)$/.exec(base);
67
77
  if (ringMatch && COLOR_TOKEN.test(ringMatch[1]))
68
78
  return true;
69
79
  return false;
70
80
  }
81
+ // ring-offset-{color} (e.g. ring-offset-blue-500) sets only the
82
+ // --tw-ring-offset-color CSS variable -- the offset ring itself is only
83
+ // ever drawn when a real ring width is *also* present (ring-2, etc.),
84
+ // verified against a real Tailwind v4 build. MODIFIER_ONLY above already
85
+ // excludes the numeric width form (ring-offset-4), but its regex requires
86
+ // digits after "offset-", so it never matched this color-shaped form --
87
+ // caught in independent adversarial testing (a real false negative: this
88
+ // fell through to the generic ring-* prefix match at the bottom of
89
+ // isReplacement and was silently accepted as a real replacement). One
90
+ // level deeper than isColorOnlyShadowOrRing above, so a separate check
91
+ // rather than folding into it.
92
+ function isColorOnlyRingOffset(base) {
93
+ const match = /^ring-offset-(.+)$/.exec(base);
94
+ return !!match && COLOR_TOKEN.test(match[1]);
95
+ }
71
96
  function baseUtility(raw) {
72
97
  return raw.slice(raw.lastIndexOf(":") + 1);
73
98
  }
@@ -79,7 +104,16 @@ function isReplacement(raw) {
79
104
  return false;
80
105
  if (isColorOnlyShadowOrRing(base))
81
106
  return false;
82
- return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
107
+ if (isColorOnlyRingOffset(base))
108
+ return false;
109
+ // inset-shadow-*/inset-ring-* (Tailwind v4) are their own prefix
110
+ // families, not `ring`/`shadow` with a suffix -- a string starting with
111
+ // "inset-" doesn't match the "shadow"/"ring" alternatives below at all,
112
+ // so a real inset-shadow-sm/inset-ring-2 replacement was previously
113
+ // rejected outright (a false positive on the overall check: it reported
114
+ // the outline as removed with nothing put back, when something real
115
+ // was). Caught in independent adversarial testing.
116
+ return /^(ring|border|shadow|bg|outline|inset-shadow|inset-ring)(-|$)/.test(base);
83
117
  }
84
118
  export function checkFocusIndicators(checks) {
85
119
  const violations = [];
@@ -115,23 +149,30 @@ const FOCUS_INDICATOR_MIN_THICKNESS_PX = 2;
115
149
  // can't currently distinguish, so bare ring/outline contributes color (if
116
150
  // paired with an explicit color utility) but never a thickness value.
117
151
  const WIDTH_SCALE = { "0": 0, "1": 1, "2": 2, "4": 4, "8": 8 };
118
- // Near-duplicate of extractClasses.ts's lastColorToken, not a call to it:
119
- // that function takes a single prefix ("text" | "bg") and this needs
120
- // last-token-wins across *two* prefixes (outline-*, ring-*) in one pass, so
121
- // whichever was actually written last in the class list wins regardless of
122
- // which utility it is. Reuses COLOR_TOKEN (the one shared "is this
123
- // color-shaped" test) and only excludes the "opacity" scale name locally --
124
- // lastColorToken's full NON_COLOR_SCALE_NAMES set also excludes
125
- // "linear"/"conic", but those are bg-gradient-angle utilities with no
126
- // outline-*/ring-* equivalent, so they can never appear here.
127
- function lastIndicatorColorToken(focusClasses) {
152
+ // Last-token-wins WITHIN one prefix only. Fixed after independent testing
153
+ // found a real bug: the original version raced outline-* against ring-*
154
+ // in one shared last-token-wins slot, so an element with BOTH a passing
155
+ // outline-* and a failing ring-* (or vice versa) got a verdict that
156
+ // depended purely on which was written later in the class string -- even
157
+ // though outline-*/ring-* are two independent CSS mechanisms
158
+ // (outline-color/-width vs. box-shadow) that both render simultaneously
159
+ // regardless of order (verified against a real Tailwind v4 build).
160
+ // Last-token-wins is still correct *within* a single prefix (e.g.
161
+ // `outline-red-500 outline-blue-600` really does render as blue-600, the
162
+ // later declaration winning in CSS) -- only racing the two prefixes
163
+ // against each other was wrong. Reuses COLOR_TOKEN (the shared
164
+ // "is this color-shaped" test) and only excludes the "opacity" scale name
165
+ // locally -- extractClasses.ts's lastColorToken's full NON_COLOR_SCALE_NAMES
166
+ // set also excludes "linear"/"conic", but those are bg-gradient-angle
167
+ // utilities with no outline-*/ring-* equivalent, so they can never appear
168
+ // here.
169
+ function lastColorTokenForIndicator(focusClasses, prefix) {
128
170
  let found = null;
129
171
  for (const raw of focusClasses) {
130
172
  const base = raw.slice(raw.lastIndexOf(":") + 1);
131
- const match = /^(?:outline|ring)-(.+)$/.exec(base);
132
- if (!match)
173
+ if (!base.startsWith(`${prefix}-`))
133
174
  continue;
134
- const rest = match[1];
175
+ const rest = base.slice(prefix.length + 1);
135
176
  if (!COLOR_TOKEN.test(rest))
136
177
  continue;
137
178
  const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
@@ -141,19 +182,18 @@ function lastIndicatorColorToken(focusClasses) {
141
182
  }
142
183
  return found;
143
184
  }
144
- // Same last-token-wins-across-both-prefixes shape as above, but for width:
145
- // only an enumerated outline-{N}/ring-{N} or an arbitrary [Npx] sets a
146
- // thickness. A color token (ring-blue-400), ring-offset-*, ring-inset, etc.
147
- // don't match either shape and are silently ignored here -- they're a
148
- // different utility's job (color, offset, inset), not this one's.
149
- function lastIndicatorThicknessPx(focusClasses) {
185
+ // Same per-prefix last-token-wins shape as above, but for width: only an
186
+ // enumerated outline-{N}/ring-{N} or an arbitrary [Npx] sets a thickness.
187
+ // A color token (ring-blue-400), ring-offset-*, ring-inset, etc. don't
188
+ // match either shape and are silently ignored here -- they're a different
189
+ // utility's job (color, offset, inset), not this one's.
190
+ function thicknessTokenForIndicator(focusClasses, prefix) {
150
191
  let found = null;
151
192
  for (const raw of focusClasses) {
152
193
  const base = raw.slice(raw.lastIndexOf(":") + 1);
153
- const match = /^(?:outline|ring)-(.+)$/.exec(base);
154
- if (!match)
194
+ if (!base.startsWith(`${prefix}-`))
155
195
  continue;
156
- const token = match[1];
196
+ const token = base.slice(prefix.length + 1);
157
197
  if (token in WIDTH_SCALE) {
158
198
  found = WIDTH_SCALE[token];
159
199
  continue;
@@ -169,26 +209,44 @@ export function checkFocusContrast(checks, strict = false, palette = defaultPale
169
209
  for (const check of checks) {
170
210
  if (!check.bgClass)
171
211
  continue; // no resolvable background — skip, not a guess
172
- const indicatorBase = lastIndicatorColorToken(check.focusClasses);
173
- if (!indicatorBase)
174
- continue; // no explicit outline-*/ring-* color — out of scope, see CLAUDE.md
175
- const indicatorHex = resolveColorValue(indicatorBase, palette);
176
212
  const bgHex = resolveColorValue(check.bgClass, palette);
177
- if (!indicatorHex || !bgHex)
213
+ const bgRgb = bgHex ? hexToRgb(bgHex) : null;
214
+ if (!bgRgb)
178
215
  continue; // custom theme color / unsupported arbitrary value — skip
179
- const indicatorRgb = hexToRgb(indicatorHex);
180
- const bgRgb = hexToRgb(bgHex);
181
- if (!indicatorRgb || !bgRgb)
182
- continue;
183
- const ratio = contrastRatio(indicatorRgb, bgRgb);
184
- const contrastFails = ratio < NON_TEXT_MIN_RATIO;
185
- let thicknessPx = null;
186
- if (strict)
187
- thicknessPx = lastIndicatorThicknessPx(check.focusClasses);
188
- const thicknessFails = strict && thicknessPx !== null && thicknessPx < FOCUS_INDICATOR_MIN_THICKNESS_PX;
189
- if (!contrastFails && !thicknessFails)
216
+ // outline-* and ring-* are evaluated as independent candidates, not
217
+ // raced against each other -- both are real, simultaneously-rendering
218
+ // mechanisms (see lastColorTokenForIndicator's comment for why), so an
219
+ // element can have zero, one, or both present at once.
220
+ const candidates = [];
221
+ for (const prefix of ["outline", "ring"]) {
222
+ const indicatorBase = lastColorTokenForIndicator(check.focusClasses, prefix);
223
+ if (!indicatorBase)
224
+ continue; // this mechanism isn't in use on this element
225
+ const indicatorHex = resolveColorValue(indicatorBase, palette);
226
+ const indicatorRgb = indicatorHex ? hexToRgb(indicatorHex) : null;
227
+ if (!indicatorRgb)
228
+ continue; // custom theme color / unsupported arbitrary value — skip this candidate
229
+ const ratio = contrastRatio(indicatorRgb, bgRgb);
230
+ const contrastFails = ratio < NON_TEXT_MIN_RATIO;
231
+ let thicknessPx = null;
232
+ if (strict)
233
+ thicknessPx = thicknessTokenForIndicator(check.focusClasses, prefix);
234
+ const thicknessFails = strict && thicknessPx !== null && thicknessPx < FOCUS_INDICATOR_MIN_THICKNESS_PX;
235
+ candidates.push({ indicatorBase, ratio, contrastFails, thicknessPx, thicknessFails });
236
+ }
237
+ if (candidates.length === 0)
238
+ continue; // no resolvable outline-*/ring-* color at all
239
+ // A user only needs ONE sufficiently visible (and, under strict,
240
+ // sufficiently thick) focus indicator to perceive the focus state --
241
+ // if any present candidate fully passes, this element is compliant
242
+ // even if another present indicator independently would have failed.
243
+ const allFail = candidates.every((c) => c.contrastFails || c.thicknessFails);
244
+ if (!allFail)
190
245
  continue;
191
- const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === indicatorBase);
246
+ // More than one candidate present and both fail -- report the worst
247
+ // (lowest-ratio) offender.
248
+ const worst = candidates.reduce((a, b) => (b.ratio < a.ratio ? b : a));
249
+ const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === worst.indicatorBase);
192
250
  violations.push({
193
251
  type: "focus-contrast",
194
252
  file: check.file,
@@ -196,11 +254,11 @@ export function checkFocusContrast(checks, strict = false, palette = defaultPale
196
254
  tagName: check.tagName,
197
255
  indicatorClass: rawIndicatorClass,
198
256
  bgClass: check.bgClass,
199
- ratio,
257
+ ratio: worst.ratio,
200
258
  required: NON_TEXT_MIN_RATIO,
201
- level: thicknessFails ? "AAA" : "AA",
202
- ...(thicknessFails && thicknessPx !== null
203
- ? { thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
259
+ level: worst.thicknessFails ? "AAA" : "AA",
260
+ ...(worst.thicknessFails && worst.thicknessPx !== null
261
+ ? { thicknessPx: worst.thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
204
262
  : {}),
205
263
  });
206
264
  }
@@ -60,22 +60,60 @@ export function checkReducedMotion(checks, strict = false) {
60
60
  return [];
61
61
  const violations = [];
62
62
  for (const check of checks) {
63
- let unscopedTransition = null;
63
+ let realTransition = null;
64
64
  let hasMotionReduceGuard = false;
65
65
  for (const raw of check.classes) {
66
66
  const base = baseUtility(raw);
67
67
  const segments = variantSegments(raw);
68
- if (segments.length === 0 && TRANSITION_BASES.has(base))
69
- unscopedTransition = raw;
70
- if (segments.includes("motion-reduce") && (base === "transition-none" || base === "transform-none")) {
68
+ // A transition counts as "real" (needs checking) unless its variant
69
+ // stack makes it not actually apply at the moment the interaction
70
+ // begins:
71
+ // - an interaction pseudo-class (hover:/focus:/focus-within:/
72
+ // active:) anywhere in the stack means the transition-property
73
+ // only exists *during* that momentary state, not before it -- CSS
74
+ // has nothing to transition *from* right as the interaction
75
+ // starts, so `hover:transition-transform hover:scale-110` still
76
+ // snaps instantly, same as before this fix (this is the one case
77
+ // the original `segments.length === 0` check happened to get
78
+ // right, so it's preserved here under its real reason instead).
79
+ // - motion-safe: anywhere in the stack means the transition simply
80
+ // doesn't exist unless motion is already safe -- a complete,
81
+ // persistent exemption, unrelated to interaction timing.
82
+ // Any *other* scoping (dark:, sm:, lg:, ...) is a persistent
83
+ // precondition, not a momentary one -- the transition genuinely is
84
+ // present in the resting state whenever that condition holds, with
85
+ // zero relationship to prefers-reduced-motion. Caught in independent
86
+ // adversarial testing: the previous version required
87
+ // `segments.length === 0` (no variant at all), so
88
+ // `dark:transition hover:scale-110` was silently treated as
89
+ // compliant even though it animates on hover in dark mode
90
+ // regardless of the user's motion preference.
91
+ const isInteractionGated = segments.some((v) => INTERACTION_VARIANTS.has(v));
92
+ const isMotionSafeGated = segments.includes("motion-safe");
93
+ if (TRANSITION_BASES.has(base) && !isInteractionGated && !isMotionSafeGated)
94
+ realTransition = raw;
95
+ // Only a *bare* motion-reduce:transition-none/transform-none (no
96
+ // other variant stacked with it) is trusted as a full guard --
97
+ // caught in independent adversarial testing: `sm:motion-reduce:
98
+ // transition-none` was being accepted as fully protective even
99
+ // though it only suppresses the transition at/above the `sm`
100
+ // breakpoint, leaving it completely unguarded below that width.
101
+ // Correctly modeling arbitrary variant-subset relationships (does
102
+ // this guard's other conditions always hold whenever the real
103
+ // trigger's conditions hold?) is out of scope -- requiring the
104
+ // guard to be unconditional is the same "skip/flag rather than
105
+ // guess" posture used everywhere else in this project, erring
106
+ // toward a false positive over the worse failure mode, a false
107
+ // negative.
108
+ if (segments.length === 1 && segments[0] === "motion-reduce" && (base === "transition-none" || base === "transform-none")) {
71
109
  hasMotionReduceGuard = true;
72
110
  }
73
111
  }
74
- // A transition scoped only under motion-safe: (never unscoped) means it
75
- // simply doesn't exist unless motion is already safe -- a complete
112
+ // No real (non-motion-safe-guarded) transition at all means it simply
113
+ // doesn't exist unless motion is already safe -- a complete
76
114
  // alternative way of satisfying 2.3.3, not a partial one -- so this
77
115
  // correctly falls through as a pass, not a skip-because-unresolvable.
78
- if (!unscopedTransition)
116
+ if (!realTransition)
79
117
  continue;
80
118
  if (hasMotionReduceGuard)
81
119
  continue;
@@ -100,7 +138,7 @@ export function checkReducedMotion(checks, strict = false) {
100
138
  file: check.file,
101
139
  line: check.line,
102
140
  tagName: check.tagName,
103
- transitionClass: unscopedTransition,
141
+ transitionClass: realTransition,
104
142
  motionClass,
105
143
  level: "AAA",
106
144
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.13.0",
3
+ "version": "0.13.5",
4
4
  "description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal/contrast — in Tailwind CSS class combinations before they ship.",
5
5
  "type": "module",
6
6
  "bin": {