tailwind-a11y 0.12.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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ npx tailwind-a11y --help # usage and all options
34
34
  Custom theme colors/spacing are read automatically, so colors and spacing outside
35
35
  Tailwind's defaults resolve too — not just the built-in palette. Both config formats
36
36
  are supported: `theme.extend.colors`/`theme.extend.spacing` in a Tailwind v3
37
- `tailwind.config.js`/`.cjs`, and `--color-*`/`--spacing-*` custom properties in a
37
+ `tailwind.config.js`/`.cjs`/`.mjs`, and `--color-*`/`--spacing-*` custom properties in a
38
38
  Tailwind v4 CSS `@theme { ... }` block (auto-detected from common paths like
39
39
  `app/globals.css`, or passed via `--config`).
40
40
 
@@ -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
  }
@@ -5,7 +5,10 @@ import { defaultPalette } from "./defaultPalette.js";
5
5
  import { spacingScale } from "./spacingScale.js";
6
6
  import { parseColorScale, parseSpacingValue } from "./themeValueParsers.js";
7
7
  import { parseThemeCss } from "./parseThemeCss.js";
8
- const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs"];
8
+ // .mjs appended last (lowest priority) -- the newly-supported format behind
9
+ // the two established ones, same "most established first" ordering
10
+ // CSS_THEME_CANDIDATES below already uses for its own list.
11
+ const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs"];
9
12
  // v1 only looks in the given directory itself -- no ancestor-directory search.
10
13
  // --config (CLI) / settings["tailwind-a11y"].configPath (ESLint) exist as
11
14
  // explicit escape hatches for projects where this isn't enough. `rootDir` must
@@ -42,15 +45,46 @@ export function findTailwindThemeCss(rootDir) {
42
45
  }
43
46
  return null;
44
47
  }
45
- // Loads a Tailwind v3-style tailwind.config.js/.cjs and extracts only
48
+ // Loads a Tailwind v3-style tailwind.config.js/.cjs/.mjs and extracts only
46
49
  // `theme.extend.colors`/`theme.extend.spacing` -- v1 does not read a full
47
- // `theme.colors`/`theme.spacing` replacement, or .mjs/.ts configs (no
48
- // config-transpiling dependency exists in this package). Tailwind v4's
50
+ // `theme.colors`/`theme.spacing` replacement, or .ts configs. Tailwind v4's
49
51
  // CSS-based `@theme` config is a separate format entirely, handled by
50
52
  // loadThemeFromCssFile()/parseThemeCss() below, not by this function.
51
53
  // `configPath` must be an absolute path (require() resolves relative paths
52
54
  // against this module's own location, not the caller's cwd).
53
55
  //
56
+ // .mjs works via plain require() -- verified this session that Node
57
+ // 20.19+/22.13+ can require() an ESM module synchronously, no import(), no
58
+ // async refactor. On an older Node this throws ERR_REQUIRE_ESM, already
59
+ // caught below and treated as "no config found," so this degrades exactly
60
+ // as gracefully as it did before .mjs was supported. Every adapter's actual
61
+ // runtime already clears the threshold: the GitHub Action runs on Node 24
62
+ // (action.yml), eslint-plugin-tailwind-a11y's own engines.node already
63
+ // excludes every version that lacks this, and the CLI's broad >=18 floor
64
+ // just falls back safely on anything older.
65
+ //
66
+ // .ts is a deliberate non-goal, not a "not yet": Node's native TypeScript
67
+ // type-stripping only activates when the *host* process is launched with
68
+ // --experimental-strip-types (verified this session -- a library can't
69
+ // turn this on for the user), so the only way to support .ts transparently
70
+ // would be promoting esbuild from a devDependency to a real runtime
71
+ // dependency of this package purely to transpile config files, a real
72
+ // native-binary weight increase. Also verified this session: a fresh
73
+ // `create-next-app --typescript --tailwind` no longer generates a JS/TS
74
+ // config file at all -- Tailwind v4 projects put theme customization in a
75
+ // CSS `@theme` block instead (see loadThemeFromCssFile() below), so .ts
76
+ // config support would only help a shrinking population of legacy
77
+ // v3-plus-TypeScript projects, not worth the dependency.
78
+ //
79
+ // Known limitation, not fixed: bustRequireCache() below does NOT work for
80
+ // a .mjs config. Node's synchronous require(esm) caches the module in its
81
+ // own internal ESM registry, not (only) in `require.cache` -- deleting the
82
+ // `require.cache` entry doesn't force a reload, confirmed with a real
83
+ // edit-and-reload test this session. CLI and GitHub Action are unaffected
84
+ // (fresh process per run either way); the VS Code extension's live-reload
85
+ // guarantee, which does work correctly for .js/.cjs/.css configs, does NOT
86
+ // extend to .mjs -- editing a .mjs config requires reloading the window.
87
+ //
54
88
  // Node's require() cache is busted before loading -- recursively, for the
55
89
  // config file *and* everything it required (e.g. a config that factors
56
90
  // tokens into a separate `require('./colors.js')`) -- without this, a
@@ -83,7 +117,20 @@ export function loadCustomTheme(configPath) {
83
117
  const cached = require.cache[resolved];
84
118
  if (cached)
85
119
  bustRequireCache(require, cached, new Set());
86
- const config = require(resolved);
120
+ const loaded = require(resolved);
121
+ // Node's require() of an ESM module returns the module namespace object
122
+ // (`{ __esModule: true, default: <the actual export>, ...named exports
123
+ // }`), not the export itself. Gated strictly on the .mjs extension --
124
+ // caught in independent review: a structural check ("does it have a
125
+ // `default` key") instead of this would silently misfire on a genuine
126
+ // CJS config that happens to export its own top-level `default` key
127
+ // (e.g. `module.exports = { default: "unrelated", theme: {...} }`),
128
+ // discarding the real theme with no error. .mjs is the only path that
129
+ // can ever produce this wrapped shape here: a `.js`/`.cjs` require()
130
+ // either returns the CJS export as-is, or -- inside a "type": "module"
131
+ // package -- throws ERR_REQUIRE_ESM before this line is ever reached
132
+ // (already handled by the catch block below, and already tested).
133
+ const config = resolved.endsWith(".mjs") && loaded && typeof loaded === "object" ? loaded.default : loaded;
87
134
  const extend = config?.theme?.extend ?? {};
88
135
  const result = {};
89
136
  if (extend.colors && typeof extend.colors === "object") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.12.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": {