tailwind-a11y 0.1.0 → 0.2.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 +34 -104
- package/dist/cli.js +11 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/rules/checkContrast.d.ts +7 -0
- package/dist/rules/checkContrast.js +38 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -1,54 +1,18 @@
|
|
|
1
1
|
# tailwind-a11y
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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">
|
|
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,47 @@ npm install --save-dev tailwind-a11y
|
|
|
58
22
|
## Usage
|
|
59
23
|
|
|
60
24
|
```bash
|
|
61
|
-
npx tailwind-a11y # scans **/*.{jsx,tsx}
|
|
62
|
-
npx tailwind-a11y "src/**/*.tsx" #
|
|
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
|
|
64
28
|
```
|
|
65
29
|
|
|
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
30
|
```
|
|
73
31
|
src/components/Card.tsx
|
|
74
|
-
3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA)
|
|
32
|
+
3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA); try text-gray-500 (4.83)
|
|
75
33
|
src/components/IconButton.tsx
|
|
76
34
|
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
35
|
|
|
79
|
-
|
|
36
|
+
2 issue(s) in 2 file(s)
|
|
80
37
|
```
|
|
81
38
|
|
|
82
|
-
Exits
|
|
39
|
+
Exits `1` on violations — safe to use as a CI gate.
|
|
83
40
|
|
|
84
|
-
|
|
85
|
-
# .github/workflows/a11y.yml
|
|
86
|
-
- run: npx tailwind-a11y
|
|
87
|
-
```
|
|
41
|
+
## Checks
|
|
88
42
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
43
|
+
| Check | WCAG | Detects |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent; suggests the nearest passing shade |
|
|
46
|
+
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px |
|
|
47
|
+
| Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
|
|
129
48
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
49
|
+
## Scope
|
|
50
|
+
|
|
51
|
+
When a case can't be resolved with confidence, it's skipped rather than guessed:
|
|
52
|
+
|
|
53
|
+
- Ancestors beyond the immediate parent, or backgrounds set inside a separate component
|
|
54
|
+
- Dynamic or computed `className` (ternaries, `clsx()`, template literals)
|
|
55
|
+
- Custom theme colors/spacing not in Tailwind's default scales
|
|
56
|
+
- Color + opacity shorthand (`bg-white/50`)
|
|
57
|
+
- Frameworks other than React/JSX
|
|
58
|
+
|
|
59
|
+
`--verbose` reports what was skipped and why — a skip is not a pass. Full rationale in
|
|
60
|
+
[CLAUDE.md](./CLAUDE.md).
|
|
61
|
+
|
|
62
|
+
## Related
|
|
63
|
+
|
|
64
|
+
- [`eslint-plugin-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/eslint-plugin-tailwind-a11y) — same checks as ESLint rules
|
|
65
|
+
- [`vscode-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/vscode-tailwind-a11y) — same checks as live editor diagnostics
|
|
136
66
|
|
|
137
67
|
## License
|
|
138
68
|
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { relative, sep } from "node:path";
|
|
3
4
|
import fg from "fast-glob";
|
|
4
5
|
import { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
|
|
5
6
|
import { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js";
|
|
@@ -9,8 +10,10 @@ import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js"
|
|
|
9
10
|
import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
10
11
|
function formatViolation(v) {
|
|
11
12
|
switch (v.type) {
|
|
12
|
-
case "contrast":
|
|
13
|
-
|
|
13
|
+
case "contrast": {
|
|
14
|
+
const base = `${v.line}: ${v.textClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (${v.level})`;
|
|
15
|
+
return v.suggestion ? `${base}; try ${v.suggestion} (${v.suggestedRatio.toFixed(2)})` : base;
|
|
16
|
+
}
|
|
14
17
|
case "touch-target":
|
|
15
18
|
return `${v.line}: <${v.tagName}> is ${v.widthPx}×${v.heightPx}px (${v.widthClass} ${v.heightClass}) — WCAG 2.5.8 requires >= 24×24px`;
|
|
16
19
|
case "focus-indicator":
|
|
@@ -42,9 +45,13 @@ async function main() {
|
|
|
42
45
|
});
|
|
43
46
|
const violations = [];
|
|
44
47
|
const skips = [];
|
|
45
|
-
for (const
|
|
48
|
+
for (const absPath of files) {
|
|
49
|
+
// Display/report path is project-root-relative (e.g. "/src/App.tsx"),
|
|
50
|
+
// not the full absolute path — reading still uses the absolute path,
|
|
51
|
+
// which is unambiguous regardless of process.cwd() quirks.
|
|
52
|
+
const file = "/" + relative(process.cwd(), absPath).split(sep).join("/");
|
|
46
53
|
try {
|
|
47
|
-
const code = readFileSync(
|
|
54
|
+
const code = readFileSync(absPath, "utf8");
|
|
48
55
|
const contrastChecks = extractChecks(code, file);
|
|
49
56
|
violations.push(...checkContrast(contrastChecks), ...checkTouchTargets(extractTouchTargetChecks(code, file)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
50
57
|
if (verbose) {
|
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.
|
|
3
|
+
"version": "0.2.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": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"import": "./dist/index.js"
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
|
-
"files": [
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
18
20
|
"engines": {
|
|
19
21
|
"node": ">=18"
|
|
20
22
|
},
|
|
@@ -25,7 +27,17 @@
|
|
|
25
27
|
"test:watch": "vitest",
|
|
26
28
|
"prepublishOnly": "npm run build && npm test"
|
|
27
29
|
},
|
|
28
|
-
"keywords": [
|
|
30
|
+
"keywords": [
|
|
31
|
+
"tailwindcss",
|
|
32
|
+
"accessibility",
|
|
33
|
+
"a11y",
|
|
34
|
+
"wcag",
|
|
35
|
+
"wcag22",
|
|
36
|
+
"contrast",
|
|
37
|
+
"touch-target",
|
|
38
|
+
"focus-visible",
|
|
39
|
+
"lint"
|
|
40
|
+
],
|
|
29
41
|
"author": "chamroro",
|
|
30
42
|
"license": "MIT",
|
|
31
43
|
"repository": {
|