tailwind-a11y 0.1.1 → 0.3.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
@@ -1,54 +1,18 @@
1
1
  # tailwind-a11y
2
2
 
3
- A static analysis engine that resolves Tailwind CSS utility classes back into their real
4
- computed values (colors, sizes, focus behavior) via AST parsing — so accessibility bugs
5
- can be caught **before rendering**, in CI, instead of at a Lighthouse audit or QA pass
6
- after the fact. Three [WCAG](https://www.w3.org/WAI/WCAG21/quickref/) checks ship on top
7
- of that engine today: color contrast, touch target size, and focus indicator removal.
3
+ CLI that catches WCAG accessibility violations in Tailwind CSS class combinations
4
+ before they ship.
8
5
 
9
- > Renamed from `tailwind-contrast-guard` once these three checks landed. This package now
10
- > lives in the [`tailwind-a11y` monorepo](https://github.com/chamroro/tailwind-a11y)
11
- > alongside its [ESLint plugin](https://github.com/chamroro/tailwind-a11y/tree/main/packages/eslint-plugin-tailwind-a11y).
12
-
13
- ## The actual problem this solves
14
-
15
- The pain isn't "checking contrast" — plenty of tools do that. The pain is finding out
16
- about an accessibility bug **late**: at a design review, an axe/Lighthouse audit, or a
17
- QA pass, days or weeks after the code that caused it was written and merged. By the time
18
- that class combination surfaces as one of forty findings on a spreadsheet, nobody
19
- remembers why `text-gray-400` ended up on that element.
20
-
21
- `tailwind-a11y` moves that feedback to write-time by actually understanding what a
22
- Tailwind class *renders as* — not just matching class names, but resolving `text-gray-400`
23
- to `#9ca3af`, `w-4` to `16px`, and evaluating those against the real WCAG formulas. That's
24
- what makes it different from a linter that only knows class *names* exist:
6
+ Resolves Tailwind utility classes into their real computed values via AST analysis,
7
+ rather than matching class names. This catches a pattern most contrast checkers miss —
8
+ background color on a parent element, text color on a child:
25
9
 
26
10
  ```jsx
27
11
  <div className="bg-white">
28
- <p className="text-gray-400">the background is on the parent, not this element</p>
12
+ <p className="text-gray-400">not caught by most tools but fails WCAG AA</p>
29
13
  </div>
30
14
  ```
31
15
 
32
- Most existing Tailwind contrast checkers only catch `text-*`/`bg-*` on the **same**
33
- element and miss this — extremely common — direct-parent pattern entirely, because they
34
- never resolve the parent's class at all.
35
-
36
- ## What's built on the engine today
37
-
38
- ```jsx
39
- <button className="w-4 h-4" onClick={...}>×</button> {/* 16×16px, fails WCAG 2.5.8 */}
40
- <button className="focus:outline-none">Save</button> {/* no visible focus indicator */}
41
- ```
42
-
43
- - **Contrast** (WCAG 1.4.3) — same-element and direct-parent `text-*`/`bg-*` combinations
44
- - **Touch target size** (WCAG 2.5.8) — interactive elements under 24×24px
45
- - **Focus indicator removal** (WCAG 2.4.7) — `focus:outline-none` with no visible replacement
46
-
47
- These three exist because they're the checks a Tailwind-aware engine can answer with high
48
- confidence today. The engine itself — turning a utility class into a real value — isn't
49
- specific to accessibility; it's the reusable part, and more checks can sit on top of it
50
- without becoming a different tool.
51
-
52
16
  ## Install
53
17
 
54
18
  ```bash
@@ -58,81 +22,49 @@ npm install --save-dev tailwind-a11y
58
22
  ## Usage
59
23
 
60
24
  ```bash
61
- npx tailwind-a11y # scans **/*.{jsx,tsx} from the current directory
62
- npx tailwind-a11y "src/**/*.tsx" # or pass your own glob pattern(s)
25
+ npx tailwind-a11y # scans **/*.{jsx,tsx}
26
+ npx tailwind-a11y "src/**/*.tsx" # custom glob
63
27
  npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
28
+ npx tailwind-a11y --version # print the installed version
29
+ npx tailwind-a11y --help # usage and all options
64
30
  ```
65
31
 
66
- `--verbose` surfaces the coverage gap explicitly instead of leaving it invisible — e.g. a
67
- custom theme color that can't be resolved, or a background set inside a wrapping
68
- component this tool can't see into. A skip is not a pass; it means "not checked."
69
-
70
- Example output:
71
-
72
32
  ```
73
33
  src/components/Card.tsx
74
- 3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA)
34
+ 3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA); try text-gray-500 (4.83)
75
35
  src/components/IconButton.tsx
76
36
  5: <button> is 16×16px (w-4 h-4) — WCAG 2.5.8 requires >= 24×24px
77
- 12: <button> removes the focus outline (focus:outline-none) with no visible replacement
78
37
 
79
- 3 issue(s) in 2 file(s)
38
+ 2 issue(s) in 2 file(s)
80
39
  ```
81
40
 
82
- Exits with code `1` when issues are found, `0` otherwise drop it into CI:
41
+ Exits `1` on violations safe to use as a CI gate.
83
42
 
84
- ```yaml
85
- # .github/workflows/a11y.yml
86
- - run: npx tailwind-a11y
87
- ```
43
+ ## Checks
88
44
 
89
- ## What it catches
90
-
91
- - **Contrast** (WCAG 1.4.3): `text-*`/`bg-*` on the **same element**, or `text-*` on a
92
- child with `bg-*` on its **immediate JSX parent** (one level up, exactly). Tailwind's
93
- default color palette, plus arbitrary hex values (`text-[#123456]`).
94
- - **Touch target size** (WCAG 2.5.8): interactive elements (`button`, `a`, `input`,
95
- `select`, `textarea`, or any element with an `onClick` handler) sized below 24×24px via
96
- explicit `w-*`/`h-*` utilities.
97
- - **Focus indicator removal** (WCAG 2.4.7): `focus:outline-none`/`focus-visible:outline-none`
98
- with no other `focus:`/`focus-visible:` utility (`ring-*`, `border-*`, `shadow-*`, `bg-*`,
99
- non-`none` `outline-*`) providing a visible replacement.
100
- - All checks: static `className="..."` string literals only.
101
-
102
- ## What it deliberately doesn't catch (v1 scope)
103
-
104
- These are intentional limitations, not bugs — each would require a fundamentally heavier
105
- tool (whole-program or runtime analysis) for a comparatively rare payoff. When a check can't
106
- be resolved with confidence, it is **skipped, not guessed** — a wrong "pass" is worse than
107
- no answer:
108
-
109
- - **Ancestors beyond the immediate parent**, or backgrounds set inside a separately-defined
110
- wrapping component (e.g. `<Card><Text/></Card>` where `Card` sets `bg-white` internally).
111
- This is the most common source of missed violations in real component-library-heavy
112
- codebases (MUI, Chakra, shadcn/ui, Radix) — resolving it would require whole-program,
113
- type-aware analysis across file boundaries, a different tool than this.
114
- - **Dynamic or computed `className`** — ternaries, template literals, `clsx()`/`cva()`
115
- composition. These are silently skipped, never guessed at.
116
- - **Custom theme colors/spacing** not in Tailwind's default scales (e.g. `text-brand-500`)
117
- - **Color + opacity shorthand** (`bg-white/50`) — skipped rather than alpha-composited,
118
- since a wrong guess is worse than no answer
119
- - **Large-text contrast thresholds** (3.0:1 instead of 4.5:1) — every check currently uses
120
- the normal-text AA threshold
121
- - **`min-w-*`/`min-h-*` sizing**, and WCAG 2.5.8's inline-text-link exception — touch target
122
- checks require explicit `w-*`+`h-*`, no fallback/exception heuristics in v1
123
- - Frameworks other than React/JSX (Vue, Svelte, Blade, …)
124
- - Editor/LSP integration — this is a CLI/CI tool, not a VS Code extension (yet)
125
-
126
- See [CLAUDE.md](./CLAUDE.md) for the full rationale behind these boundaries.
127
-
128
- ## Development
45
+ | Check | WCAG | Detects |
46
+ |---|---|---|
47
+ | Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent; suggests the nearest passing shade |
48
+ | Touch target | 2.5.8 (AA) | Interactive elements under 24×24px |
49
+ | Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
129
50
 
130
- ```bash
131
- npm install
132
- npm run dev -- "src/**/*.tsx" # run the CLI against a project without building
133
- npm test # vitest
134
- npm run build # tsc -> dist/
135
- ```
51
+ ## Scope
52
+
53
+ When a case can't be resolved with confidence, it's skipped rather than guessed:
54
+
55
+ - Ancestors beyond the immediate parent, or backgrounds set inside a separate component
56
+ - Dynamic or computed `className` (ternaries, `clsx()`, template literals)
57
+ - Custom theme colors/spacing not in Tailwind's default scales
58
+ - Color + opacity shorthand (`bg-white/50`)
59
+ - Frameworks other than React/JSX
60
+
61
+ `--verbose` reports what was skipped and why — a skip is not a pass. Full rationale in
62
+ [CLAUDE.md](./CLAUDE.md).
63
+
64
+ ## Related
65
+
66
+ - [`eslint-plugin-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/eslint-plugin-tailwind-a11y) — same checks as ESLint rules
67
+ - [`vscode-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/vscode-tailwind-a11y) — same checks as live editor diagnostics
136
68
 
137
69
  ## License
138
70
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, sep } from "node:path";
4
+ import { createRequire } from "node:module";
4
5
  import fg from "fast-glob";
5
6
  import { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
6
7
  import { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js";
@@ -8,10 +9,16 @@ import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extr
8
9
  import { checkTouchTargets } from "./rules/checkTouchTarget.js";
9
10
  import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
10
11
  import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
12
+ import { parseArgs, getHelpText } from "./cliArgs.js";
13
+ // ../package.json resolves correctly from both src/ (dev) and dist/ (published).
14
+ const require = createRequire(import.meta.url);
15
+ const { version: packageVersion } = require("../package.json");
11
16
  function formatViolation(v) {
12
17
  switch (v.type) {
13
- case "contrast":
14
- return `${v.line}: ${v.textClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (${v.level})`;
18
+ case "contrast": {
19
+ const base = `${v.line}: ${v.textClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (${v.level})`;
20
+ return v.suggestion ? `${base}; try ${v.suggestion} (${v.suggestedRatio.toFixed(2)})` : base;
21
+ }
15
22
  case "touch-target":
16
23
  return `${v.line}: <${v.tagName}> is ${v.widthPx}×${v.heightPx}px (${v.widthClass} ${v.heightClass}) — WCAG 2.5.8 requires >= 24×24px`;
17
24
  case "focus-indicator":
@@ -32,9 +39,15 @@ function groupByFile(items) {
32
39
  return byFile;
33
40
  }
34
41
  async function main() {
35
- const args = process.argv.slice(2);
36
- const verbose = args.includes("--verbose") || args.includes("-v");
37
- const patterns = args.filter((a) => a !== "--verbose" && a !== "-v");
42
+ const { help, version, verbose, patterns } = parseArgs(process.argv.slice(2));
43
+ if (help) {
44
+ console.log(getHelpText());
45
+ return;
46
+ }
47
+ if (version) {
48
+ console.log(packageVersion);
49
+ return;
50
+ }
38
51
  const globPatterns = patterns.length > 0 ? patterns : ["**/*.{jsx,tsx}"];
39
52
  const files = await fg(globPatterns, {
40
53
  cwd: process.cwd(),
@@ -0,0 +1,8 @@
1
+ export interface ParsedArgs {
2
+ help: boolean;
3
+ version: boolean;
4
+ verbose: boolean;
5
+ patterns: string[];
6
+ }
7
+ export declare function getHelpText(): string;
8
+ export declare function parseArgs(argv: string[]): ParsedArgs;
@@ -0,0 +1,31 @@
1
+ // Split out from cli.ts so it's importable in tests without triggering
2
+ // cli.ts's top-level main() call (which does real file I/O on import).
3
+ const HELP_TEXT = `Usage: tailwind-a11y [options] [<glob>...]
4
+
5
+ Static analysis for Tailwind CSS accessibility violations -- color contrast,
6
+ touch target size, and focus indicator removal.
7
+
8
+ Options:
9
+ -v, --verbose Also report what couldn't be checked, and why
10
+ -V, --version Print the version number
11
+ -h, --help Print this help message
12
+
13
+ Examples:
14
+ tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
15
+ tailwind-a11y "src/**/*.tsx" Scan a custom glob pattern
16
+ tailwind-a11y --verbose Also report skipped/unresolvable cases
17
+ `;
18
+ export function getHelpText() {
19
+ return HELP_TEXT;
20
+ }
21
+ // -v/--verbose already existed before --version was added; -V (uppercase)
22
+ // avoids colliding with it, matching a common CLI convention.
23
+ const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h"]);
24
+ export function parseArgs(argv) {
25
+ return {
26
+ help: argv.includes("--help") || argv.includes("-h"),
27
+ version: argv.includes("--version") || argv.includes("-V"),
28
+ verbose: argv.includes("--verbose") || argv.includes("-v"),
29
+ patterns: argv.filter((a) => !FLAGS.has(a)),
30
+ };
31
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { extractChecks, extractContrastSkips, type ContrastCheck, type ContrastSkip } from "./parser/extractClasses.js";
2
- export { checkContrast, checkContrastValueSkips, type ContrastViolation, type ContrastValueSkip } from "./rules/checkContrast.js";
2
+ export { checkContrast, checkContrastValueSkips, suggestContrastFix, type ContrastViolation, type ContrastValueSkip, type ContrastFix } from "./rules/checkContrast.js";
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";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
2
- export { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js";
2
+ export { checkContrast, checkContrastValueSkips, suggestContrastFix } from "./rules/checkContrast.js";
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";
@@ -8,9 +8,16 @@ export interface ContrastViolation {
8
8
  ratio: number;
9
9
  required: number;
10
10
  level: "AA";
11
+ suggestion?: string;
12
+ suggestedRatio?: number;
11
13
  }
12
14
  export declare function resolveColorValue(utilityClass: string): string | null;
13
15
  export declare function checkContrast(checks: ContrastCheck[]): ContrastViolation[];
16
+ export interface ContrastFix {
17
+ textClass: string;
18
+ ratio: number;
19
+ }
20
+ export declare function suggestContrastFix(textClass: string, bgClass: string, required: number): ContrastFix | null;
14
21
  export interface ContrastValueSkip {
15
22
  file: string;
16
23
  line: number;
@@ -33,6 +33,7 @@ export function checkContrast(checks) {
33
33
  const ratio = contrastRatio(textRgb, bgRgb);
34
34
  const required = requiredRatio("AA", false); // v1: large-text detection deferred
35
35
  if (!meetsWCAG(ratio, "AA", false)) {
36
+ const fix = suggestContrastFix(check.textColorClass, check.bgColorClass, required);
36
37
  violations.push({
37
38
  type: "contrast",
38
39
  file: check.file,
@@ -42,11 +43,48 @@ export function checkContrast(checks) {
42
43
  ratio,
43
44
  required,
44
45
  level: "AA",
46
+ ...(fix && { suggestion: fix.textClass, suggestedRatio: fix.ratio }),
45
47
  });
46
48
  }
47
49
  }
48
50
  return violations;
49
51
  }
52
+ const TEXT_SCALE_SHADE_RE = /^text-([a-z]+)-(\d+)$/;
53
+ // Only the text shade moves — bg stays fixed, since text color is the more
54
+ // commonly adjustable side in practice. Candidates come from the palette's
55
+ // actual keys (not an assumed 50..950 enumeration), sorted nearest-first by
56
+ // numeric distance from the original shade; ties favor the higher/darker
57
+ // shade, since real failures here are overwhelmingly light-on-light and
58
+ // darker is the fix a human reaches for. The original shade can never win:
59
+ // it's in this same candidate list at distance 0, and this recomputes the
60
+ // identical unrounded ratio comparison that just failed.
61
+ export function suggestContrastFix(textClass, bgClass, required) {
62
+ const match = TEXT_SCALE_SHADE_RE.exec(textClass);
63
+ if (!match)
64
+ return null; // text-white, text-[#eee], text-gray-400/50 — no suggestion
65
+ const [, scale, shade] = match;
66
+ const shades = defaultPalette[scale];
67
+ if (!shades?.[shade])
68
+ return null; // custom scale, or a decoy like text-opacity-50
69
+ const bgHex = resolveColorValue(bgClass);
70
+ const bgRgb = bgHex ? hexToRgb(bgHex) : null;
71
+ if (!bgRgb)
72
+ return null;
73
+ const original = Number(shade);
74
+ const candidates = Object.keys(shades)
75
+ .filter((s) => /^\d+$/.test(s))
76
+ .map(Number)
77
+ .sort((a, b) => Math.abs(a - original) - Math.abs(b - original) || b - a);
78
+ for (const candidate of candidates) {
79
+ const rgb = hexToRgb(shades[String(candidate)]);
80
+ if (!rgb)
81
+ continue;
82
+ const ratio = contrastRatio(rgb, bgRgb);
83
+ if (ratio >= required)
84
+ return { textClass: `text-${scale}-${candidate}`, ratio };
85
+ }
86
+ return null;
87
+ }
50
88
  // A candidate that extractChecks *did* find a background for, but whose
51
89
  // text or bg utility didn't resolve to a known value (custom theme color,
52
90
  // non-hex arbitrary value, opacity shorthand) — surfaced separately from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.1.1",
3
+ "version": "0.3.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": {