tailwind-a11y 0.8.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,6 +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: WCAG 2.5.5 (AAA, 44x44px); focus indicators: also WCAG 2.4.13 (AAA, min thickness)
28
29
  npx tailwind-a11y --config ./tw.config.cjs # use a specific config instead of auto-detecting
29
30
  npx tailwind-a11y --version # print the installed version
30
31
  npx tailwind-a11y --help # usage and all options
@@ -53,8 +54,9 @@ Exits `1` on violations — safe to use as a CI gate.
53
54
  | Check | WCAG | Detects |
54
55
  |---|---|---|
55
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 |
56
- | Touch target | 2.5.8 (AA) | Interactive elements under 24×24px |
57
+ | Touch target | 2.5.8 (AA) | Interactive elements under 24×24px — or 44×44px with `--strict` (2.5.5, AAA) |
57
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) |
58
60
 
59
61
  ## Scope
60
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).
@@ -20,10 +20,19 @@ function formatViolation(v) {
20
20
  const base = `${v.line}: ${v.textClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (${v.level})`;
21
21
  return v.suggestion ? `${base}; try ${v.suggestion} (${v.suggestedRatio.toFixed(2)})` : base;
22
22
  }
23
- case "touch-target":
24
- return `${v.line}: <${v.tagName}> is ${v.widthPx}×${v.heightPx}px (${v.widthClass} ${v.heightClass}) WCAG 2.5.8 requires >= 24×24px`;
23
+ case "touch-target": {
24
+ const sc = v.level === "AAA" ? "2.5.5" : "2.5.8";
25
+ return `${v.line}: <${v.tagName}> is ${v.widthPx}×${v.heightPx}px (${v.widthClass} ${v.heightClass}) — WCAG ${sc} requires >= ${v.required}×${v.required}px`;
26
+ }
25
27
  case "focus-indicator":
26
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
+ }
27
36
  }
28
37
  }
29
38
  function groupByFile(items) {
@@ -40,7 +49,7 @@ function groupByFile(items) {
40
49
  return byFile;
41
50
  }
42
51
  async function main() {
43
- const { help, version, verbose, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
52
+ const { help, version, verbose, strict, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
44
53
  if (help) {
45
54
  console.log(getHelpText());
46
55
  return;
@@ -77,7 +86,8 @@ async function main() {
77
86
  try {
78
87
  const code = readFileSync(absPath, "utf8");
79
88
  const contrastChecks = extractChecks(code, file);
80
- violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing)), ...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));
81
91
  if (verbose) {
82
92
  skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
83
93
  }
package/dist/cliArgs.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface ParsedArgs {
2
2
  help: boolean;
3
3
  version: boolean;
4
4
  verbose: boolean;
5
+ strict: boolean;
5
6
  config: string | null;
6
7
  configError: string | null;
7
8
  patterns: string[];
package/dist/cliArgs.js CHANGED
@@ -3,12 +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
+ --strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
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)
12
16
  --config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
13
17
  @theme file like app/globals.css (v4) to read
14
18
  custom theme colors/spacing from (default:
@@ -18,6 +22,7 @@ Examples:
18
22
  tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
19
23
  tailwind-a11y "src/**/*.tsx" Scan a custom glob pattern
20
24
  tailwind-a11y --verbose Also report skipped/unresolvable cases
25
+ tailwind-a11y --strict Enforce the stricter 44x44px touch target minimum
21
26
  tailwind-a11y --config ./tailwind.config.cjs
22
27
  `;
23
28
  export function getHelpText() {
@@ -25,7 +30,7 @@ export function getHelpText() {
25
30
  }
26
31
  // -v/--verbose already existed before --version was added; -V (uppercase)
27
32
  // avoids colliding with it, matching a common CLI convention.
28
- const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h"]);
33
+ const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h", "--strict"]);
29
34
  export function parseArgs(argv) {
30
35
  let config = null;
31
36
  let configError = null;
@@ -53,6 +58,7 @@ export function parseArgs(argv) {
53
58
  help: argv.includes("--help") || argv.includes("-h"),
54
59
  version: argv.includes("--version") || argv.includes("-V"),
55
60
  verbose: argv.includes("--verbose") || argv.includes("-v"),
61
+ strict: argv.includes("--strict"),
56
62
  config,
57
63
  configError,
58
64
  patterns,
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
+ }
@@ -8,5 +8,7 @@ export interface TouchTargetViolation {
8
8
  heightClass: string;
9
9
  widthPx: number;
10
10
  heightPx: number;
11
+ required: number;
12
+ level: "AA" | "AAA";
11
13
  }
12
- export declare function checkTouchTargets(checks: TouchTargetCheck[]): TouchTargetViolation[];
14
+ export declare function checkTouchTargets(checks: TouchTargetCheck[], strict?: boolean): TouchTargetViolation[];
@@ -1,8 +1,17 @@
1
1
  // WCAG 2.5.8 Target Size (Minimum), Level AA: interactive targets must be
2
2
  // at least 24x24 CSS pixels. "Minimum" is inclusive, so exactly 24x24 passes.
3
3
  const MIN_TARGET_PX = 24;
4
- export function checkTouchTargets(checks) {
4
+ // WCAG 2.5.5 Target Size (Enhanced), Level AAA: 44x44 CSS pixels -- opt-in
5
+ // via `strict`, not the default. Same "target in a sentence/text block"
6
+ // exemption as 2.5.8 (verified against the W3C Understanding doc), so
7
+ // extractTouchTargets.ts's isInlineInText() exemption logic applies
8
+ // unchanged to both thresholds -- this file only changes which number is
9
+ // compared against, not how targets are found or exempted.
10
+ const MIN_TARGET_PX_STRICT = 44;
11
+ export function checkTouchTargets(checks, strict = false) {
12
+ const required = strict ? MIN_TARGET_PX_STRICT : MIN_TARGET_PX;
13
+ const level = strict ? "AAA" : "AA";
5
14
  return checks
6
- .filter((c) => c.widthPx < MIN_TARGET_PX || c.heightPx < MIN_TARGET_PX)
7
- .map((c) => ({ type: "touch-target", ...c }));
15
+ .filter((c) => c.widthPx < required || c.heightPx < required)
16
+ .map((c) => ({ type: "touch-target", ...c, required, level }));
8
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.8.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": {