tailwind-a11y 0.13.0 → 0.13.1

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
  }
@@ -115,23 +115,30 @@ const FOCUS_INDICATOR_MIN_THICKNESS_PX = 2;
115
115
  // can't currently distinguish, so bare ring/outline contributes color (if
116
116
  // paired with an explicit color utility) but never a thickness value.
117
117
  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) {
118
+ // Last-token-wins WITHIN one prefix only. Fixed after independent testing
119
+ // found a real bug: the original version raced outline-* against ring-*
120
+ // in one shared last-token-wins slot, so an element with BOTH a passing
121
+ // outline-* and a failing ring-* (or vice versa) got a verdict that
122
+ // depended purely on which was written later in the class string -- even
123
+ // though outline-*/ring-* are two independent CSS mechanisms
124
+ // (outline-color/-width vs. box-shadow) that both render simultaneously
125
+ // regardless of order (verified against a real Tailwind v4 build).
126
+ // Last-token-wins is still correct *within* a single prefix (e.g.
127
+ // `outline-red-500 outline-blue-600` really does render as blue-600, the
128
+ // later declaration winning in CSS) -- only racing the two prefixes
129
+ // against each other was wrong. Reuses COLOR_TOKEN (the shared
130
+ // "is this color-shaped" test) and only excludes the "opacity" scale name
131
+ // locally -- extractClasses.ts's lastColorToken's full NON_COLOR_SCALE_NAMES
132
+ // set also excludes "linear"/"conic", but those are bg-gradient-angle
133
+ // utilities with no outline-*/ring-* equivalent, so they can never appear
134
+ // here.
135
+ function lastColorTokenForIndicator(focusClasses, prefix) {
128
136
  let found = null;
129
137
  for (const raw of focusClasses) {
130
138
  const base = raw.slice(raw.lastIndexOf(":") + 1);
131
- const match = /^(?:outline|ring)-(.+)$/.exec(base);
132
- if (!match)
139
+ if (!base.startsWith(`${prefix}-`))
133
140
  continue;
134
- const rest = match[1];
141
+ const rest = base.slice(prefix.length + 1);
135
142
  if (!COLOR_TOKEN.test(rest))
136
143
  continue;
137
144
  const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
@@ -141,19 +148,18 @@ function lastIndicatorColorToken(focusClasses) {
141
148
  }
142
149
  return found;
143
150
  }
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) {
151
+ // Same per-prefix last-token-wins shape as above, but for width: only an
152
+ // enumerated outline-{N}/ring-{N} or an arbitrary [Npx] sets a thickness.
153
+ // A color token (ring-blue-400), ring-offset-*, ring-inset, etc. don't
154
+ // match either shape and are silently ignored here -- they're a different
155
+ // utility's job (color, offset, inset), not this one's.
156
+ function thicknessTokenForIndicator(focusClasses, prefix) {
150
157
  let found = null;
151
158
  for (const raw of focusClasses) {
152
159
  const base = raw.slice(raw.lastIndexOf(":") + 1);
153
- const match = /^(?:outline|ring)-(.+)$/.exec(base);
154
- if (!match)
160
+ if (!base.startsWith(`${prefix}-`))
155
161
  continue;
156
- const token = match[1];
162
+ const token = base.slice(prefix.length + 1);
157
163
  if (token in WIDTH_SCALE) {
158
164
  found = WIDTH_SCALE[token];
159
165
  continue;
@@ -169,26 +175,44 @@ export function checkFocusContrast(checks, strict = false, palette = defaultPale
169
175
  for (const check of checks) {
170
176
  if (!check.bgClass)
171
177
  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
178
  const bgHex = resolveColorValue(check.bgClass, palette);
177
- if (!indicatorHex || !bgHex)
179
+ const bgRgb = bgHex ? hexToRgb(bgHex) : null;
180
+ if (!bgRgb)
178
181
  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)
182
+ // outline-* and ring-* are evaluated as independent candidates, not
183
+ // raced against each other -- both are real, simultaneously-rendering
184
+ // mechanisms (see lastColorTokenForIndicator's comment for why), so an
185
+ // element can have zero, one, or both present at once.
186
+ const candidates = [];
187
+ for (const prefix of ["outline", "ring"]) {
188
+ const indicatorBase = lastColorTokenForIndicator(check.focusClasses, prefix);
189
+ if (!indicatorBase)
190
+ continue; // this mechanism isn't in use on this element
191
+ const indicatorHex = resolveColorValue(indicatorBase, palette);
192
+ const indicatorRgb = indicatorHex ? hexToRgb(indicatorHex) : null;
193
+ if (!indicatorRgb)
194
+ continue; // custom theme color / unsupported arbitrary value — skip this candidate
195
+ const ratio = contrastRatio(indicatorRgb, bgRgb);
196
+ const contrastFails = ratio < NON_TEXT_MIN_RATIO;
197
+ let thicknessPx = null;
198
+ if (strict)
199
+ thicknessPx = thicknessTokenForIndicator(check.focusClasses, prefix);
200
+ const thicknessFails = strict && thicknessPx !== null && thicknessPx < FOCUS_INDICATOR_MIN_THICKNESS_PX;
201
+ candidates.push({ indicatorBase, ratio, contrastFails, thicknessPx, thicknessFails });
202
+ }
203
+ if (candidates.length === 0)
204
+ continue; // no resolvable outline-*/ring-* color at all
205
+ // A user only needs ONE sufficiently visible (and, under strict,
206
+ // sufficiently thick) focus indicator to perceive the focus state --
207
+ // if any present candidate fully passes, this element is compliant
208
+ // even if another present indicator independently would have failed.
209
+ const allFail = candidates.every((c) => c.contrastFails || c.thicknessFails);
210
+ if (!allFail)
190
211
  continue;
191
- const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === indicatorBase);
212
+ // More than one candidate present and both fail -- report the worst
213
+ // (lowest-ratio) offender.
214
+ const worst = candidates.reduce((a, b) => (b.ratio < a.ratio ? b : a));
215
+ const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === worst.indicatorBase);
192
216
  violations.push({
193
217
  type: "focus-contrast",
194
218
  file: check.file,
@@ -196,11 +220,11 @@ export function checkFocusContrast(checks, strict = false, palette = defaultPale
196
220
  tagName: check.tagName,
197
221
  indicatorClass: rawIndicatorClass,
198
222
  bgClass: check.bgClass,
199
- ratio,
223
+ ratio: worst.ratio,
200
224
  required: NON_TEXT_MIN_RATIO,
201
- level: thicknessFails ? "AAA" : "AA",
202
- ...(thicknessFails && thicknessPx !== null
203
- ? { thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
225
+ level: worst.thicknessFails ? "AAA" : "AA",
226
+ ...(worst.thicknessFails && worst.thicknessPx !== null
227
+ ? { thicknessPx: worst.thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
204
228
  : {}),
205
229
  });
206
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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": {