tailwind-a11y 0.9.0 → 0.10.0

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
@@ -25,7 +25,7 @@ npm install --save-dev tailwind-a11y
25
25
  npx tailwind-a11y # scans **/*.{jsx,tsx}
26
26
  npx tailwind-a11y "src/**/*.tsx" # custom glob
27
27
  npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
28
- npx tailwind-a11y --strict # touch targets must meet WCAG 2.5.5 (AAA, 44x44px), not just 2.5.8 (AA, 24x24px)
28
+ npx tailwind-a11y --strict # touch targets: WCAG 2.5.5 (AAA, 44x44px); focus indicators: also WCAG 2.4.13 (AAA, min thickness)
29
29
  npx tailwind-a11y --config ./tw.config.cjs # use a specific config instead of auto-detecting
30
30
  npx tailwind-a11y --version # print the installed version
31
31
  npx tailwind-a11y --help # usage and all options
@@ -56,6 +56,7 @@ Exits `1` on violations — safe to use as a CI gate.
56
56
  | Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent, including a text-side opacity modifier (`text-gray-400/50`) composited against the background; suggests the nearest passing shade |
57
57
  | Touch target | 2.5.8 (AA) | Interactive elements under 24×24px — or 44×44px with `--strict` (2.5.5, AAA) |
58
58
  | Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
59
+ | Focus indicator contrast | 1.4.11 (AA) | A present `outline-*`/`ring-*` focus indicator below 3:1 contrast — or also below the 2px minimum thickness with `--strict` (2.4.13, AAA) |
59
60
 
60
61
  ## Scope
61
62
 
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js
8
8
  import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
9
9
  import { checkTouchTargets } from "./rules/checkTouchTarget.js";
10
10
  import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
11
- import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
11
+ import { checkFocusContrast, checkFocusIndicators, } from "./rules/checkFocusIndicator.js";
12
12
  import { parseArgs, getHelpText } from "./cliArgs.js";
13
13
  import { resolveTheme } from "./theme/loadCustomTheme.js";
14
14
  // ../package.json resolves correctly from both src/ (dev) and dist/ (published).
@@ -26,6 +26,13 @@ function formatViolation(v) {
26
26
  }
27
27
  case "focus-indicator":
28
28
  return `${v.line}: <${v.tagName}> removes the focus outline (${v.removalClass}) with no visible replacement (focus:ring-*/border-*/shadow-*/bg-*/outline-*)`;
29
+ case "focus-contrast": {
30
+ const sc = v.level === "AAA" ? "2.4.13" : "1.4.11";
31
+ const base = `${v.line}: <${v.tagName}> focus indicator ${v.indicatorClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (WCAG ${sc})`;
32
+ return v.thicknessPx !== undefined
33
+ ? `${base}; also only ${v.thicknessPx}px thick, needs >= ${v.requiredThicknessPx}px`
34
+ : base;
35
+ }
29
36
  }
30
37
  }
31
38
  function groupByFile(items) {
@@ -79,7 +86,8 @@ async function main() {
79
86
  try {
80
87
  const code = readFileSync(absPath, "utf8");
81
88
  const contrastChecks = extractChecks(code, file);
82
- violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
89
+ const focusChecks = extractFocusIndicatorChecks(code, file);
90
+ violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette));
83
91
  if (verbose) {
84
92
  skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
85
93
  }
package/dist/cliArgs.js CHANGED
@@ -3,14 +3,16 @@
3
3
  const HELP_TEXT = `Usage: tailwind-a11y [options] [<glob>...]
4
4
 
5
5
  Static analysis for Tailwind CSS accessibility violations -- color contrast,
6
- touch target size, and focus indicator removal.
6
+ touch target size, and focus indicator removal/contrast.
7
7
 
8
8
  Options:
9
9
  -v, --verbose Also report what couldn't be checked, and why
10
10
  -V, --version Print the version number
11
11
  -h, --help Print this help message
12
12
  --strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
13
- instead of the default 2.5.8 (AA, 24x24px)
13
+ instead of the default 2.5.8 (AA, 24x24px); focus
14
+ indicators are also held to 2.4.13 (AAA, minimum
15
+ thickness) alongside the default 1.4.11 (AA, contrast)
14
16
  --config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
15
17
  @theme file like app/globals.css (v4) to read
16
18
  custom theme colors/spacing from (default:
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { checkContrast, checkContrastValueSkips, suggestContrastFix, type Contra
3
3
  export { extractTouchTargetChecks, extractTouchTargetSkips, type TouchTargetCheck, type TouchTargetSkip } from "./parser/extractTouchTargets.js";
4
4
  export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouchTarget.js";
5
5
  export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
6
- export { checkFocusIndicators, type FocusIndicatorViolation } from "./rules/checkFocusIndicator.js";
6
+ export { checkFocusIndicators, checkFocusContrast, type FocusIndicatorViolation, type FocusContrastViolation, } from "./rules/checkFocusIndicator.js";
7
7
  export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
8
8
  export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
9
9
  export { parseThemeCss } from "./theme/parseThemeCss.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { checkContrast, checkContrastValueSkips, suggestContrastFix } from "./ru
3
3
  export { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
4
4
  export { checkTouchTargets } from "./rules/checkTouchTarget.js";
5
5
  export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
6
- export { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
6
+ export { checkFocusIndicators, checkFocusContrast, } from "./rules/checkFocusIndicator.js";
7
7
  export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
8
8
  export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
9
9
  export { parseThemeCss } from "./theme/parseThemeCss.js";
@@ -3,5 +3,7 @@ export interface FocusIndicatorCheck {
3
3
  line: number;
4
4
  tagName: string;
5
5
  focusClasses: string[];
6
+ bgClass?: string | null;
7
+ bgSource?: "self" | "parent" | null;
6
8
  }
7
9
  export declare function extractFocusIndicatorChecks(code: string, filePath: string): FocusIndicatorCheck[];
@@ -1,6 +1,7 @@
1
1
  import * as t from "@babel/types";
2
2
  import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
3
3
  import { isInteractiveElement } from "./isInteractiveElement.js";
4
+ import { lastColorToken } from "./extractClasses.js";
4
5
  function focusScopedClasses(className) {
5
6
  return className
6
7
  .split(/\s+/)
@@ -27,11 +28,30 @@ export function extractFocusIndicatorChecks(code, filePath) {
27
28
  const focusClasses = focusScopedClasses(className);
28
29
  if (focusClasses.length === 0)
29
30
  return; // nothing under focus:/focus-visible: — not a candidate
31
+ const ownBg = lastColorToken(className, "bg");
32
+ let bgClass = ownBg;
33
+ let bgSource = ownBg ? "self" : null;
34
+ if (!bgClass) {
35
+ // Only the immediate JSX parent, same limit as extractClasses.ts's
36
+ // own contrast resolution — no deeper ancestor walk, no
37
+ // cross-component resolution.
38
+ const parentNode = path.parentPath?.node;
39
+ if (parentNode && t.isJSXElement(parentNode)) {
40
+ const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
41
+ const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
42
+ if (parentBg) {
43
+ bgClass = parentBg;
44
+ bgSource = "parent";
45
+ }
46
+ }
47
+ }
30
48
  checks.push({
31
49
  file: filePath,
32
50
  line: opening.loc?.start.line ?? 0,
33
51
  tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "onClick-element",
34
52
  focusClasses,
53
+ bgClass,
54
+ bgSource,
35
55
  });
36
56
  },
37
57
  });
@@ -1,7 +1,10 @@
1
1
  import { applyAlpha, contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
2
2
  import { defaultPalette, semanticColors } from "../theme/defaultPalette.js";
3
3
  export function resolveColorValue(utilityClass, palette = defaultPalette) {
4
- const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
4
+ // outline/ring are here for checkFocusIndicator.ts's non-text-contrast
5
+ // check (WCAG 1.4.11/2.4.13) -- same palette/arbitrary-hex/semantic-color
6
+ // resolution as text/bg, just a different utility prefix.
7
+ const match = /^(?:text|bg|outline|ring)-(.+)$/.exec(utilityClass);
5
8
  if (!match)
6
9
  return null;
7
10
  const token = match[1];
@@ -1,4 +1,5 @@
1
1
  import type { FocusIndicatorCheck } from "../parser/extractFocusIndicators.js";
2
+ import type { Palette } from "../theme/defaultPalette.js";
2
3
  export interface FocusIndicatorViolation {
3
4
  type: "focus-indicator";
4
5
  file: string;
@@ -7,3 +8,17 @@ export interface FocusIndicatorViolation {
7
8
  removalClass: string;
8
9
  }
9
10
  export declare function checkFocusIndicators(checks: FocusIndicatorCheck[]): FocusIndicatorViolation[];
11
+ export interface FocusContrastViolation {
12
+ type: "focus-contrast";
13
+ file: string;
14
+ line: number;
15
+ tagName: string;
16
+ indicatorClass: string;
17
+ bgClass: string;
18
+ ratio: number;
19
+ required: number;
20
+ level: "AA" | "AAA";
21
+ thicknessPx?: number;
22
+ requiredThicknessPx?: number;
23
+ }
24
+ export declare function checkFocusContrast(checks: FocusIndicatorCheck[], strict?: boolean, palette?: Palette): FocusContrastViolation[];
@@ -1,4 +1,7 @@
1
1
  import { COLOR_TOKEN } from "../parser/extractClasses.js";
2
+ import { resolveColorValue } from "./checkContrast.js";
3
+ import { contrastRatio, hexToRgb } from "../contrast/luminance.js";
4
+ import { defaultPalette } from "../theme/defaultPalette.js";
2
5
  const REMOVAL_BASE = "outline-none";
3
6
  // Utilities that match the "replacement" shape but are semantically no-ops —
4
7
  // the same failure mode as bg-opacity-50 masking a real color match: a
@@ -97,3 +100,109 @@ export function checkFocusIndicators(checks) {
97
100
  }
98
101
  return violations;
99
102
  }
103
+ // Flat non-text threshold -- not luminance.ts's requiredRatio()/meetsWCAG(),
104
+ // which model the text-specific large-text/small-text AA/AAA table that
105
+ // doesn't apply to UI-component contrast.
106
+ const NON_TEXT_MIN_RATIO = 3;
107
+ // WCAG 2.4.13's "2 CSS pixel thick perimeter" -- verified against a real
108
+ // tailwindcss@4.3.3 compile (see CLAUDE.md) rather than assumed.
109
+ const FOCUS_INDICATOR_MIN_THICKNESS_PX = 2;
110
+ // outline-{0,1,2,4,8} / ring-{0,1,2,4,8} -- Tailwind's fixed width scale for
111
+ // both utilities, verified against a real build. Bare `ring`/`outline` (no
112
+ // digit) is deliberately NOT in this map: verified via that same build that
113
+ // it resolves to 1px in Tailwind v4, but v3's bare `ring` default is
114
+ // documented elsewhere as 3px -- a real cross-version difference this tool
115
+ // can't currently distinguish, so bare ring/outline contributes color (if
116
+ // paired with an explicit color utility) but never a thickness value.
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) {
128
+ let found = null;
129
+ for (const raw of focusClasses) {
130
+ const base = raw.slice(raw.lastIndexOf(":") + 1);
131
+ const match = /^(?:outline|ring)-(.+)$/.exec(base);
132
+ if (!match)
133
+ continue;
134
+ const rest = match[1];
135
+ if (!COLOR_TOKEN.test(rest))
136
+ continue;
137
+ const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
138
+ if (scaleName === "opacity")
139
+ continue;
140
+ found = base;
141
+ }
142
+ return found;
143
+ }
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) {
150
+ let found = null;
151
+ for (const raw of focusClasses) {
152
+ const base = raw.slice(raw.lastIndexOf(":") + 1);
153
+ const match = /^(?:outline|ring)-(.+)$/.exec(base);
154
+ if (!match)
155
+ continue;
156
+ const token = match[1];
157
+ if (token in WIDTH_SCALE) {
158
+ found = WIDTH_SCALE[token];
159
+ continue;
160
+ }
161
+ const arbitrary = /^\[(\d+(?:\.\d+)?)px\]$/.exec(token);
162
+ if (arbitrary)
163
+ found = Number(arbitrary[1]);
164
+ }
165
+ return found;
166
+ }
167
+ export function checkFocusContrast(checks, strict = false, palette = defaultPalette) {
168
+ const violations = [];
169
+ for (const check of checks) {
170
+ if (!check.bgClass)
171
+ 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
+ const bgHex = resolveColorValue(check.bgClass, palette);
177
+ if (!indicatorHex || !bgHex)
178
+ 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)
190
+ continue;
191
+ const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === indicatorBase);
192
+ violations.push({
193
+ type: "focus-contrast",
194
+ file: check.file,
195
+ line: check.line,
196
+ tagName: check.tagName,
197
+ indicatorClass: rawIndicatorClass,
198
+ bgClass: check.bgClass,
199
+ ratio,
200
+ required: NON_TEXT_MIN_RATIO,
201
+ level: thicknessFails ? "AAA" : "AA",
202
+ ...(thicknessFails && thicknessPx !== null
203
+ ? { thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
204
+ : {}),
205
+ });
206
+ }
207
+ return violations;
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal — in Tailwind CSS class combinations before they ship.",
5
5
  "type": "module",
6
6
  "bin": {