tailwind-a11y 0.1.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/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +88 -0
- package/dist/contrast/luminance.d.ts +10 -0
- package/dist/contrast/luminance.js +42 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/parser/babelInterop.d.ts +9 -0
- package/dist/parser/babelInterop.js +21 -0
- package/dist/parser/extractClasses.d.ts +15 -0
- package/dist/parser/extractClasses.js +112 -0
- package/dist/parser/extractFocusIndicators.d.ts +7 -0
- package/dist/parser/extractFocusIndicators.js +39 -0
- package/dist/parser/extractTouchTargets.d.ts +16 -0
- package/dist/parser/extractTouchTargets.js +95 -0
- package/dist/parser/isInteractiveElement.d.ts +2 -0
- package/dist/parser/isInteractiveElement.js +8 -0
- package/dist/rules/checkContrast.d.ts +19 -0
- package/dist/rules/checkContrast.js +70 -0
- package/dist/rules/checkFocusIndicator.d.ts +9 -0
- package/dist/rules/checkFocusIndicator.js +41 -0
- package/dist/rules/checkTouchTarget.d.ts +12 -0
- package/dist/rules/checkTouchTarget.js +8 -0
- package/dist/theme/defaultPalette.d.ts +4 -0
- package/dist/theme/defaultPalette.js +300 -0
- package/dist/theme/spacingScale.d.ts +1 -0
- package/dist/theme/spacingScale.js +47 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chamroro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# tailwind-a11y
|
|
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.
|
|
8
|
+
|
|
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:
|
|
25
|
+
|
|
26
|
+
```jsx
|
|
27
|
+
<div className="bg-white">
|
|
28
|
+
<p className="text-gray-400">the background is on the parent, not this element</p>
|
|
29
|
+
</div>
|
|
30
|
+
```
|
|
31
|
+
|
|
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
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install --save-dev tailwind-a11y
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```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)
|
|
63
|
+
npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
|
|
64
|
+
```
|
|
65
|
+
|
|
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
|
+
```
|
|
73
|
+
src/components/Card.tsx
|
|
74
|
+
3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA)
|
|
75
|
+
src/components/IconButton.tsx
|
|
76
|
+
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
|
+
|
|
79
|
+
3 issue(s) in 2 file(s)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Exits with code `1` when issues are found, `0` otherwise — drop it into CI:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
# .github/workflows/a11y.yml
|
|
86
|
+
- run: npx tailwind-a11y
|
|
87
|
+
```
|
|
88
|
+
|
|
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
|
|
129
|
+
|
|
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
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
|
|
5
|
+
import { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js";
|
|
6
|
+
import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
|
|
7
|
+
import { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
8
|
+
import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
9
|
+
import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
10
|
+
function formatViolation(v) {
|
|
11
|
+
switch (v.type) {
|
|
12
|
+
case "contrast":
|
|
13
|
+
return `${v.line}: ${v.textClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (${v.level})`;
|
|
14
|
+
case "touch-target":
|
|
15
|
+
return `${v.line}: <${v.tagName}> is ${v.widthPx}×${v.heightPx}px (${v.widthClass} ${v.heightClass}) — WCAG 2.5.8 requires >= 24×24px`;
|
|
16
|
+
case "focus-indicator":
|
|
17
|
+
return `${v.line}: <${v.tagName}> removes the focus outline (${v.removalClass}) with no visible replacement (focus:ring-*/border-*/shadow-*/bg-*/outline-*)`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function groupByFile(items) {
|
|
21
|
+
const byFile = new Map();
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
const existing = byFile.get(item.file);
|
|
24
|
+
if (existing) {
|
|
25
|
+
existing.push(item);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
byFile.set(item.file, [item]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return byFile;
|
|
32
|
+
}
|
|
33
|
+
async function main() {
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
36
|
+
const patterns = args.filter((a) => a !== "--verbose" && a !== "-v");
|
|
37
|
+
const globPatterns = patterns.length > 0 ? patterns : ["**/*.{jsx,tsx}"];
|
|
38
|
+
const files = await fg(globPatterns, {
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
absolute: true,
|
|
41
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/build/**"],
|
|
42
|
+
});
|
|
43
|
+
const violations = [];
|
|
44
|
+
const skips = [];
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
try {
|
|
47
|
+
const code = readFileSync(file, "utf8");
|
|
48
|
+
const contrastChecks = extractChecks(code, file);
|
|
49
|
+
violations.push(...checkContrast(contrastChecks), ...checkTouchTargets(extractTouchTargetChecks(code, file)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
50
|
+
if (verbose) {
|
|
51
|
+
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks), ...extractTouchTargetSkips(code, file));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
// Consistent with the parser's own skip-on-unparsable-file behavior:
|
|
56
|
+
// one unreadable file (permissions, deleted between glob and read)
|
|
57
|
+
// shouldn't abort the whole scan.
|
|
58
|
+
console.warn(`tailwind-a11y: skipping unreadable file ${file}: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (violations.length === 0) {
|
|
62
|
+
console.log("No accessibility issues found.");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const byFile = groupByFile(violations);
|
|
66
|
+
for (const [file, fileViolations] of byFile) {
|
|
67
|
+
console.log(file);
|
|
68
|
+
for (const v of fileViolations) {
|
|
69
|
+
console.log(` ${formatViolation(v)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
console.log(`\n${violations.length} issue(s) in ${byFile.size} file(s)`);
|
|
73
|
+
}
|
|
74
|
+
if (verbose && skips.length > 0) {
|
|
75
|
+
console.log(`\n--- Skipped (${skips.length}) — not checked, not a pass ---`);
|
|
76
|
+
const byFile = groupByFile(skips);
|
|
77
|
+
for (const [file, fileSkips] of byFile) {
|
|
78
|
+
console.log(file);
|
|
79
|
+
for (const s of fileSkips) {
|
|
80
|
+
console.log(` ${s.line}: ${s.reason}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (violations.length > 0) {
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
main();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RGB {
|
|
2
|
+
r: number;
|
|
3
|
+
g: number;
|
|
4
|
+
b: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function hexToRgb(hex: string): RGB | null;
|
|
7
|
+
export declare function relativeLuminance(rgb: RGB): number;
|
|
8
|
+
export declare function contrastRatio(rgb1: RGB, rgb2: RGB): number;
|
|
9
|
+
export declare function requiredRatio(level: "AA" | "AAA", isLargeText: boolean): number;
|
|
10
|
+
export declare function meetsWCAG(ratio: number, level: "AA" | "AAA", isLargeText: boolean): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const HEX_RE = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
2
|
+
export function hexToRgb(hex) {
|
|
3
|
+
const match = HEX_RE.exec(hex);
|
|
4
|
+
if (!match)
|
|
5
|
+
return null;
|
|
6
|
+
let digits = match[1];
|
|
7
|
+
if (digits.length === 3) {
|
|
8
|
+
digits = digits
|
|
9
|
+
.split("")
|
|
10
|
+
.map((c) => c + c)
|
|
11
|
+
.join("");
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
r: parseInt(digits.slice(0, 2), 16),
|
|
15
|
+
g: parseInt(digits.slice(2, 4), 16),
|
|
16
|
+
b: parseInt(digits.slice(4, 6), 16),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function channelLuminance(channel) {
|
|
20
|
+
const c = channel / 255;
|
|
21
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
22
|
+
}
|
|
23
|
+
export function relativeLuminance(rgb) {
|
|
24
|
+
return (0.2126 * channelLuminance(rgb.r) +
|
|
25
|
+
0.7152 * channelLuminance(rgb.g) +
|
|
26
|
+
0.0722 * channelLuminance(rgb.b));
|
|
27
|
+
}
|
|
28
|
+
export function contrastRatio(rgb1, rgb2) {
|
|
29
|
+
const l1 = relativeLuminance(rgb1);
|
|
30
|
+
const l2 = relativeLuminance(rgb2);
|
|
31
|
+
const lighter = Math.max(l1, l2);
|
|
32
|
+
const darker = Math.min(l1, l2);
|
|
33
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
34
|
+
}
|
|
35
|
+
export function requiredRatio(level, isLargeText) {
|
|
36
|
+
if (level === "AAA")
|
|
37
|
+
return isLargeText ? 4.5 : 7.0;
|
|
38
|
+
return isLargeText ? 3.0 : 4.5;
|
|
39
|
+
}
|
|
40
|
+
export function meetsWCAG(ratio, level, isLargeText) {
|
|
41
|
+
return ratio >= requiredRatio(level, isLargeText);
|
|
42
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { extractChecks, extractContrastSkips, type ContrastCheck, type ContrastSkip } from "./parser/extractClasses.js";
|
|
2
|
+
export { checkContrast, checkContrastValueSkips, type ContrastViolation, type ContrastValueSkip } from "./rules/checkContrast.js";
|
|
3
|
+
export { extractTouchTargetChecks, extractTouchTargetSkips, type TouchTargetCheck, type TouchTargetSkip } from "./parser/extractTouchTargets.js";
|
|
4
|
+
export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouchTarget.js";
|
|
5
|
+
export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
|
|
6
|
+
export { checkFocusIndicators, type FocusIndicatorViolation } from "./rules/checkFocusIndicator.js";
|
|
7
|
+
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
|
|
2
|
+
export { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js";
|
|
3
|
+
export { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
|
|
4
|
+
export { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
5
|
+
export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
6
|
+
export { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
7
|
+
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NodePath } from "@babel/traverse";
|
|
2
|
+
import * as t from "@babel/types";
|
|
3
|
+
type TraverseFn = (ast: t.File, visitor: {
|
|
4
|
+
JSXElement?: (path: NodePath<t.JSXElement>) => void;
|
|
5
|
+
}) => void;
|
|
6
|
+
export declare const traverse: TraverseFn;
|
|
7
|
+
export declare function parseJSX(code: string, filePath: string): t.File | null;
|
|
8
|
+
export declare function getStaticClassName(attributes: t.JSXOpeningElement["attributes"]): string | null;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
import _traverse from "@babel/traverse";
|
|
3
|
+
import * as t from "@babel/types";
|
|
4
|
+
export const traverse = (typeof _traverse === "function" ? _traverse : _traverse.default);
|
|
5
|
+
export function parseJSX(code, filePath) {
|
|
6
|
+
try {
|
|
7
|
+
return parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
console.warn(`tailwind-a11y: skipping unparsable file ${filePath}`);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function getStaticClassName(attributes) {
|
|
15
|
+
const attr = attributes.find((a) => t.isJSXAttribute(a) && a.name.name === "className");
|
|
16
|
+
if (!attr || !attr.value)
|
|
17
|
+
return null;
|
|
18
|
+
if (t.isStringLiteral(attr.value))
|
|
19
|
+
return attr.value.value;
|
|
20
|
+
return null; // JSXExpressionContainer (ternary, template literal, clsx()...) — skip silently
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ContrastCheck {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
textColorClass: string;
|
|
5
|
+
bgColorClass: string;
|
|
6
|
+
bgSource: "self" | "parent";
|
|
7
|
+
}
|
|
8
|
+
export declare function lastColorToken(className: string, prefix: "text" | "bg"): string | null;
|
|
9
|
+
export declare function extractChecks(code: string, filePath: string): ContrastCheck[];
|
|
10
|
+
export interface ContrastSkip {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function extractContrastSkips(code: string, filePath: string): ContrastSkip[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
|
+
// Positive shape filter for "does this look like a color utility", not a
|
|
4
|
+
// blocklist — Tailwind heavily overloads the text-*/bg-* prefix (text-lg,
|
|
5
|
+
// bg-cover, bg-gradient-to-r, ...) and an exclude-list would be fragile
|
|
6
|
+
// across versions.
|
|
7
|
+
const COLOR_TOKEN = /^\[(#[0-9a-fA-F]{3,8})\]$|^[a-z]+-\d{2,3}(\/\d{1,3})?$|^(white|black|transparent|current|inherit)$/;
|
|
8
|
+
// opacity-{N} (e.g. bg-opacity-50, text-opacity-50) matches the same
|
|
9
|
+
// "word-number" shape as a color token but isn't one — without this
|
|
10
|
+
// exclusion it can silently overwrite a real color match via the
|
|
11
|
+
// last-token-wins rule below, making a real violation vanish (a false
|
|
12
|
+
// negative, the worst failure mode for a linter).
|
|
13
|
+
const NON_COLOR_SCALE_NAMES = new Set(["opacity"]);
|
|
14
|
+
export function lastColorToken(className, prefix) {
|
|
15
|
+
let found = null;
|
|
16
|
+
for (const raw of className.split(/\s+/).filter(Boolean)) {
|
|
17
|
+
const base = raw.slice(raw.lastIndexOf(":") + 1); // strip hover:/dark:/md: variants
|
|
18
|
+
if (!base.startsWith(`${prefix}-`))
|
|
19
|
+
continue;
|
|
20
|
+
const rest = base.slice(prefix.length + 1);
|
|
21
|
+
if (!COLOR_TOKEN.test(rest))
|
|
22
|
+
continue;
|
|
23
|
+
const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
|
|
24
|
+
if (scaleName && NON_COLOR_SCALE_NAMES.has(scaleName))
|
|
25
|
+
continue;
|
|
26
|
+
found = base;
|
|
27
|
+
}
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
export function extractChecks(code, filePath) {
|
|
31
|
+
const ast = parseJSX(code, filePath);
|
|
32
|
+
if (!ast)
|
|
33
|
+
return [];
|
|
34
|
+
const checks = [];
|
|
35
|
+
traverse(ast, {
|
|
36
|
+
JSXElement(path) {
|
|
37
|
+
const className = getStaticClassName(path.node.openingElement.attributes);
|
|
38
|
+
if (!className)
|
|
39
|
+
return;
|
|
40
|
+
const textClass = lastColorToken(className, "text");
|
|
41
|
+
if (!textClass)
|
|
42
|
+
return;
|
|
43
|
+
const line = path.node.openingElement.loc?.start.line ?? 0;
|
|
44
|
+
const ownBg = lastColorToken(className, "bg");
|
|
45
|
+
if (ownBg) {
|
|
46
|
+
checks.push({ file: filePath, line, textColorClass: textClass, bgColorClass: ownBg, bgSource: "self" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Only the immediate JSX parent is considered — no deeper ancestor
|
|
50
|
+
// walk and no cross-component resolution (see CLAUDE.md scope).
|
|
51
|
+
const parentNode = path.parentPath?.node;
|
|
52
|
+
if (parentNode && t.isJSXElement(parentNode)) {
|
|
53
|
+
const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
|
|
54
|
+
const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
|
|
55
|
+
if (parentBg) {
|
|
56
|
+
checks.push({ file: filePath, line, textColorClass: textClass, bgColorClass: parentBg, bgSource: "parent" });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return checks;
|
|
62
|
+
}
|
|
63
|
+
// Independent pass (not merged into extractChecks) purely to surface why a
|
|
64
|
+
// text color candidate produced no check — most usefully, the component-
|
|
65
|
+
// boundary case: <Card><p className="text-gray-400">...</Card> where Card
|
|
66
|
+
// sets its background internally, in a file this tool never opens. This is
|
|
67
|
+
// a real, common miss (see CLAUDE.md's v1 scope), so it's made visible
|
|
68
|
+
// rather than silently invisible, without attempting to actually resolve it.
|
|
69
|
+
export function extractContrastSkips(code, filePath) {
|
|
70
|
+
const ast = parseJSX(code, filePath);
|
|
71
|
+
if (!ast)
|
|
72
|
+
return [];
|
|
73
|
+
const skips = [];
|
|
74
|
+
traverse(ast, {
|
|
75
|
+
JSXElement(path) {
|
|
76
|
+
const className = getStaticClassName(path.node.openingElement.attributes);
|
|
77
|
+
if (!className)
|
|
78
|
+
return;
|
|
79
|
+
const textClass = lastColorToken(className, "text");
|
|
80
|
+
if (!textClass)
|
|
81
|
+
return;
|
|
82
|
+
const ownBg = lastColorToken(className, "bg");
|
|
83
|
+
if (ownBg)
|
|
84
|
+
return; // extractChecks already covers this case
|
|
85
|
+
const line = path.node.openingElement.loc?.start.line ?? 0;
|
|
86
|
+
const parentNode = path.parentPath?.node;
|
|
87
|
+
if (parentNode && t.isJSXElement(parentNode)) {
|
|
88
|
+
const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
|
|
89
|
+
const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
|
|
90
|
+
if (parentBg)
|
|
91
|
+
return; // extractChecks already covers this case
|
|
92
|
+
const parentTag = t.isJSXIdentifier(parentNode.openingElement.name)
|
|
93
|
+
? parentNode.openingElement.name.name
|
|
94
|
+
: null;
|
|
95
|
+
if (parentTag && /^[A-Z]/.test(parentTag)) {
|
|
96
|
+
skips.push({
|
|
97
|
+
file: filePath,
|
|
98
|
+
line,
|
|
99
|
+
reason: `${textClass} — background may be set inside <${parentTag}>, which this tool doesn't inspect across component boundaries`,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
skips.push({
|
|
105
|
+
file: filePath,
|
|
106
|
+
line,
|
|
107
|
+
reason: `${textClass} — no background utility found on this element or its immediate parent`,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return skips;
|
|
112
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
|
+
import { isInteractiveElement } from "./isInteractiveElement.js";
|
|
4
|
+
function focusScopedClasses(className) {
|
|
5
|
+
return className
|
|
6
|
+
.split(/\s+/)
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.filter((raw) => {
|
|
9
|
+
const segments = raw.split(":");
|
|
10
|
+
const variant = segments[segments.length - 2]; // the variant immediately before the utility
|
|
11
|
+
return variant === "focus" || variant === "focus-visible";
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function extractFocusIndicatorChecks(code, filePath) {
|
|
15
|
+
const ast = parseJSX(code, filePath);
|
|
16
|
+
if (!ast)
|
|
17
|
+
return [];
|
|
18
|
+
const checks = [];
|
|
19
|
+
traverse(ast, {
|
|
20
|
+
JSXElement(path) {
|
|
21
|
+
const opening = path.node.openingElement;
|
|
22
|
+
if (!isInteractiveElement(opening))
|
|
23
|
+
return;
|
|
24
|
+
const className = getStaticClassName(opening.attributes);
|
|
25
|
+
if (!className)
|
|
26
|
+
return;
|
|
27
|
+
const focusClasses = focusScopedClasses(className);
|
|
28
|
+
if (focusClasses.length === 0)
|
|
29
|
+
return; // nothing under focus:/focus-visible: — not a candidate
|
|
30
|
+
checks.push({
|
|
31
|
+
file: filePath,
|
|
32
|
+
line: opening.loc?.start.line ?? 0,
|
|
33
|
+
tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "onClick-element",
|
|
34
|
+
focusClasses,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return checks;
|
|
39
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface TouchTargetCheck {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
tagName: string;
|
|
5
|
+
widthClass: string;
|
|
6
|
+
heightClass: string;
|
|
7
|
+
widthPx: number;
|
|
8
|
+
heightPx: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function extractTouchTargetChecks(code: string, filePath: string): TouchTargetCheck[];
|
|
11
|
+
export interface TouchTargetSkip {
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function extractTouchTargetSkips(code: string, filePath: string): TouchTargetSkip[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
|
+
import { isInteractiveElement } from "./isInteractiveElement.js";
|
|
4
|
+
import { spacingScale } from "../theme/spacingScale.js";
|
|
5
|
+
// Only unprefixed w-*/h-* tokens count — a variant-scoped size like
|
|
6
|
+
// hover:w-24 does not describe the resting-state box, so it must not be
|
|
7
|
+
// allowed to overwrite a real base match via "last token wins" (the same
|
|
8
|
+
// failure mode as bg-opacity-50 masking bg-white in the contrast checker).
|
|
9
|
+
function lastSizeToken(tokens, prefix) {
|
|
10
|
+
let found = null;
|
|
11
|
+
for (const raw of tokens) {
|
|
12
|
+
if (raw.includes(":"))
|
|
13
|
+
continue;
|
|
14
|
+
if (!raw.startsWith(`${prefix}-`))
|
|
15
|
+
continue;
|
|
16
|
+
found = { raw, value: raw.slice(prefix.length + 1) };
|
|
17
|
+
}
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
export function extractTouchTargetChecks(code, filePath) {
|
|
21
|
+
const ast = parseJSX(code, filePath);
|
|
22
|
+
if (!ast)
|
|
23
|
+
return [];
|
|
24
|
+
const checks = [];
|
|
25
|
+
traverse(ast, {
|
|
26
|
+
JSXElement(path) {
|
|
27
|
+
const opening = path.node.openingElement;
|
|
28
|
+
if (!isInteractiveElement(opening))
|
|
29
|
+
return;
|
|
30
|
+
const className = getStaticClassName(opening.attributes);
|
|
31
|
+
if (!className)
|
|
32
|
+
return;
|
|
33
|
+
const tokens = className.split(/\s+/).filter(Boolean);
|
|
34
|
+
const width = lastSizeToken(tokens, "w");
|
|
35
|
+
const height = lastSizeToken(tokens, "h");
|
|
36
|
+
if (!width || !height)
|
|
37
|
+
return; // either dimension missing/dynamic — skip, don't guess
|
|
38
|
+
const widthPx = spacingScale[width.value];
|
|
39
|
+
const heightPx = spacingScale[height.value];
|
|
40
|
+
if (widthPx === undefined || heightPx === undefined)
|
|
41
|
+
return; // arbitrary/keyword/fraction — skip
|
|
42
|
+
checks.push({
|
|
43
|
+
file: filePath,
|
|
44
|
+
line: opening.loc?.start.line ?? 0,
|
|
45
|
+
tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "onClick-element",
|
|
46
|
+
widthClass: width.raw,
|
|
47
|
+
heightClass: height.raw,
|
|
48
|
+
widthPx,
|
|
49
|
+
heightPx,
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return checks;
|
|
54
|
+
}
|
|
55
|
+
// Independent pass surfacing why an interactive element with size-related
|
|
56
|
+
// classes produced no check: either only one of w-*/h-* is present, or a
|
|
57
|
+
// present value isn't in the default spacing scale (arbitrary, keyword, or
|
|
58
|
+
// fraction). Elements with neither w-* nor h-* at all aren't reported —
|
|
59
|
+
// that's the overwhelming majority of interactive elements and would be
|
|
60
|
+
// pure noise, not a meaningful skip.
|
|
61
|
+
export function extractTouchTargetSkips(code, filePath) {
|
|
62
|
+
const ast = parseJSX(code, filePath);
|
|
63
|
+
if (!ast)
|
|
64
|
+
return [];
|
|
65
|
+
const skips = [];
|
|
66
|
+
traverse(ast, {
|
|
67
|
+
JSXElement(path) {
|
|
68
|
+
const opening = path.node.openingElement;
|
|
69
|
+
if (!isInteractiveElement(opening))
|
|
70
|
+
return;
|
|
71
|
+
const className = getStaticClassName(opening.attributes);
|
|
72
|
+
if (!className)
|
|
73
|
+
return;
|
|
74
|
+
const tokens = className.split(/\s+/).filter(Boolean);
|
|
75
|
+
const width = lastSizeToken(tokens, "w");
|
|
76
|
+
const height = lastSizeToken(tokens, "h");
|
|
77
|
+
if (!width && !height)
|
|
78
|
+
return; // no size classes at all — not a meaningful skip
|
|
79
|
+
const line = opening.loc?.start.line ?? 0;
|
|
80
|
+
if (!width || !height) {
|
|
81
|
+
const found = width ?? height;
|
|
82
|
+
const missing = width ? "height" : "width";
|
|
83
|
+
skips.push({ file: filePath, line, reason: `${found.raw} present but no ${missing} utility set — skipped` });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const widthPx = spacingScale[width.value];
|
|
87
|
+
const heightPx = spacingScale[height.value];
|
|
88
|
+
if (widthPx === undefined || heightPx === undefined) {
|
|
89
|
+
const bad = widthPx === undefined ? width.raw : height.raw;
|
|
90
|
+
skips.push({ file: filePath, line, reason: `${bad} is not in the default spacing scale (arbitrary, keyword, or fraction value) — skipped` });
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return skips;
|
|
95
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
const INTERACTIVE_TAGS = new Set(["button", "a", "input", "select", "textarea"]);
|
|
3
|
+
export function isInteractiveElement(opening) {
|
|
4
|
+
const tagName = t.isJSXIdentifier(opening.name) ? opening.name.name : null;
|
|
5
|
+
if (tagName !== null && INTERACTIVE_TAGS.has(tagName))
|
|
6
|
+
return true;
|
|
7
|
+
return opening.attributes.some((a) => t.isJSXAttribute(a) && a.name.name === "onClick");
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ContrastCheck } from "../parser/extractClasses.js";
|
|
2
|
+
export interface ContrastViolation {
|
|
3
|
+
type: "contrast";
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
textClass: string;
|
|
7
|
+
bgClass: string;
|
|
8
|
+
ratio: number;
|
|
9
|
+
required: number;
|
|
10
|
+
level: "AA";
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveColorValue(utilityClass: string): string | null;
|
|
13
|
+
export declare function checkContrast(checks: ContrastCheck[]): ContrastViolation[];
|
|
14
|
+
export interface ContrastValueSkip {
|
|
15
|
+
file: string;
|
|
16
|
+
line: number;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function checkContrastValueSkips(checks: ContrastCheck[]): ContrastValueSkip[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
|
|
2
|
+
import { defaultPalette, semanticColors } from "../theme/defaultPalette.js";
|
|
3
|
+
export function resolveColorValue(utilityClass) {
|
|
4
|
+
const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
|
|
5
|
+
if (!match)
|
|
6
|
+
return null;
|
|
7
|
+
const token = match[1];
|
|
8
|
+
const arbitrary = /^\[(#[0-9a-fA-F]{3,8})\]$/.exec(token);
|
|
9
|
+
if (arbitrary)
|
|
10
|
+
return arbitrary[1];
|
|
11
|
+
if (token.startsWith("["))
|
|
12
|
+
return null; // non-hex arbitrary (url()/var()/rgb()) — skip
|
|
13
|
+
if (token.includes("/"))
|
|
14
|
+
return null; // opacity shorthand — skip rather than approximate
|
|
15
|
+
if (token in semanticColors)
|
|
16
|
+
return semanticColors[token];
|
|
17
|
+
const [scale, shade] = token.split("-");
|
|
18
|
+
if (!scale || !shade)
|
|
19
|
+
return null;
|
|
20
|
+
return defaultPalette[scale]?.[shade] ?? null; // unknown/custom color — skip
|
|
21
|
+
}
|
|
22
|
+
export function checkContrast(checks) {
|
|
23
|
+
const violations = [];
|
|
24
|
+
for (const check of checks) {
|
|
25
|
+
const textHex = resolveColorValue(check.textColorClass);
|
|
26
|
+
const bgHex = resolveColorValue(check.bgColorClass);
|
|
27
|
+
if (!textHex || !bgHex)
|
|
28
|
+
continue;
|
|
29
|
+
const textRgb = hexToRgb(textHex);
|
|
30
|
+
const bgRgb = hexToRgb(bgHex);
|
|
31
|
+
if (!textRgb || !bgRgb)
|
|
32
|
+
continue;
|
|
33
|
+
const ratio = contrastRatio(textRgb, bgRgb);
|
|
34
|
+
const required = requiredRatio("AA", false); // v1: large-text detection deferred
|
|
35
|
+
if (!meetsWCAG(ratio, "AA", false)) {
|
|
36
|
+
violations.push({
|
|
37
|
+
type: "contrast",
|
|
38
|
+
file: check.file,
|
|
39
|
+
line: check.line,
|
|
40
|
+
textClass: check.textColorClass,
|
|
41
|
+
bgClass: check.bgColorClass,
|
|
42
|
+
ratio,
|
|
43
|
+
required,
|
|
44
|
+
level: "AA",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return violations;
|
|
49
|
+
}
|
|
50
|
+
// A candidate that extractChecks *did* find a background for, but whose
|
|
51
|
+
// text or bg utility didn't resolve to a known value (custom theme color,
|
|
52
|
+
// non-hex arbitrary value, opacity shorthand) — surfaced separately from
|
|
53
|
+
// extractContrastSkips' component-boundary case, since this one already has
|
|
54
|
+
// a full text/bg pair and only failed at value resolution.
|
|
55
|
+
export function checkContrastValueSkips(checks) {
|
|
56
|
+
const skips = [];
|
|
57
|
+
for (const check of checks) {
|
|
58
|
+
const textHex = resolveColorValue(check.textColorClass);
|
|
59
|
+
const bgHex = resolveColorValue(check.bgColorClass);
|
|
60
|
+
if (textHex && bgHex)
|
|
61
|
+
continue;
|
|
62
|
+
const unresolved = !textHex ? check.textColorClass : check.bgColorClass;
|
|
63
|
+
skips.push({
|
|
64
|
+
file: check.file,
|
|
65
|
+
line: check.line,
|
|
66
|
+
reason: `${unresolved} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return skips;
|
|
70
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FocusIndicatorCheck } from "../parser/extractFocusIndicators.js";
|
|
2
|
+
export interface FocusIndicatorViolation {
|
|
3
|
+
type: "focus-indicator";
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
tagName: string;
|
|
7
|
+
removalClass: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function checkFocusIndicators(checks: FocusIndicatorCheck[]): FocusIndicatorViolation[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const REMOVAL_BASE = "outline-none";
|
|
2
|
+
// Utilities that match the "replacement" shape but are semantically no-ops —
|
|
3
|
+
// the same failure mode as bg-opacity-50 masking a real color match: a
|
|
4
|
+
// same-prefix decoy that would silently hide a real violation if we only
|
|
5
|
+
// checked the prefix.
|
|
6
|
+
const DEGENERATE_BASES = new Set(["outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent"]);
|
|
7
|
+
// Modifier-only utilities (opacity/offset/inset) don't set a concrete value
|
|
8
|
+
// on their own — e.g. bg-opacity-50 with no bg-* color, or ring-offset-4
|
|
9
|
+
// with no ring-* width, renders nothing visible by itself. Same failure
|
|
10
|
+
// mode as DEGENERATE_BASES above, but suffix-shaped rather than a fixed
|
|
11
|
+
// set (opacity/offset take arbitrary numeric values), so matched with a
|
|
12
|
+
// pattern instead of enumerated.
|
|
13
|
+
const MODIFIER_ONLY = /^(bg|border|ring)-opacity-\d{1,3}$|^(ring|outline)-offset-\d{1,3}$|^ring-inset$/;
|
|
14
|
+
function baseUtility(raw) {
|
|
15
|
+
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
16
|
+
}
|
|
17
|
+
function isReplacement(raw) {
|
|
18
|
+
const base = baseUtility(raw);
|
|
19
|
+
if (DEGENERATE_BASES.has(base) || MODIFIER_ONLY.test(base))
|
|
20
|
+
return false;
|
|
21
|
+
return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
|
|
22
|
+
}
|
|
23
|
+
export function checkFocusIndicators(checks) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
for (const check of checks) {
|
|
26
|
+
const removal = check.focusClasses.find((raw) => baseUtility(raw) === REMOVAL_BASE);
|
|
27
|
+
if (!removal)
|
|
28
|
+
continue;
|
|
29
|
+
const hasReplacement = check.focusClasses.some(isReplacement);
|
|
30
|
+
if (hasReplacement)
|
|
31
|
+
continue;
|
|
32
|
+
violations.push({
|
|
33
|
+
type: "focus-indicator",
|
|
34
|
+
file: check.file,
|
|
35
|
+
line: check.line,
|
|
36
|
+
tagName: check.tagName,
|
|
37
|
+
removalClass: removal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return violations;
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TouchTargetCheck } from "../parser/extractTouchTargets.js";
|
|
2
|
+
export interface TouchTargetViolation {
|
|
3
|
+
type: "touch-target";
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
tagName: string;
|
|
7
|
+
widthClass: string;
|
|
8
|
+
heightClass: string;
|
|
9
|
+
widthPx: number;
|
|
10
|
+
heightPx: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function checkTouchTargets(checks: TouchTargetCheck[]): TouchTargetViolation[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// WCAG 2.5.8 Target Size (Minimum), Level AA: interactive targets must be
|
|
2
|
+
// at least 24x24 CSS pixels. "Minimum" is inclusive, so exactly 24x24 passes.
|
|
3
|
+
const MIN_TARGET_PX = 24;
|
|
4
|
+
export function checkTouchTargets(checks) {
|
|
5
|
+
return checks
|
|
6
|
+
.filter((c) => c.widthPx < MIN_TARGET_PX || c.heightPx < MIN_TARGET_PX)
|
|
7
|
+
.map((c) => ({ type: "touch-target", ...c }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Snapshot of tailwindcss/colors (default palette), generated once at
|
|
2
|
+
// authoring time rather than read from the consumer's project — see
|
|
3
|
+
// CLAUDE.md: v1 does not read a user's tailwind.config.
|
|
4
|
+
export const defaultPalette = {
|
|
5
|
+
slate: {
|
|
6
|
+
"50": "#f8fafc",
|
|
7
|
+
"100": "#f1f5f9",
|
|
8
|
+
"200": "#e2e8f0",
|
|
9
|
+
"300": "#cbd5e1",
|
|
10
|
+
"400": "#94a3b8",
|
|
11
|
+
"500": "#64748b",
|
|
12
|
+
"600": "#475569",
|
|
13
|
+
"700": "#334155",
|
|
14
|
+
"800": "#1e293b",
|
|
15
|
+
"900": "#0f172a",
|
|
16
|
+
"950": "#020617",
|
|
17
|
+
},
|
|
18
|
+
gray: {
|
|
19
|
+
"50": "#f9fafb",
|
|
20
|
+
"100": "#f3f4f6",
|
|
21
|
+
"200": "#e5e7eb",
|
|
22
|
+
"300": "#d1d5db",
|
|
23
|
+
"400": "#9ca3af",
|
|
24
|
+
"500": "#6b7280",
|
|
25
|
+
"600": "#4b5563",
|
|
26
|
+
"700": "#374151",
|
|
27
|
+
"800": "#1f2937",
|
|
28
|
+
"900": "#111827",
|
|
29
|
+
"950": "#030712",
|
|
30
|
+
},
|
|
31
|
+
zinc: {
|
|
32
|
+
"50": "#fafafa",
|
|
33
|
+
"100": "#f4f4f5",
|
|
34
|
+
"200": "#e4e4e7",
|
|
35
|
+
"300": "#d4d4d8",
|
|
36
|
+
"400": "#a1a1aa",
|
|
37
|
+
"500": "#71717a",
|
|
38
|
+
"600": "#52525b",
|
|
39
|
+
"700": "#3f3f46",
|
|
40
|
+
"800": "#27272a",
|
|
41
|
+
"900": "#18181b",
|
|
42
|
+
"950": "#09090b",
|
|
43
|
+
},
|
|
44
|
+
neutral: {
|
|
45
|
+
"50": "#fafafa",
|
|
46
|
+
"100": "#f5f5f5",
|
|
47
|
+
"200": "#e5e5e5",
|
|
48
|
+
"300": "#d4d4d4",
|
|
49
|
+
"400": "#a3a3a3",
|
|
50
|
+
"500": "#737373",
|
|
51
|
+
"600": "#525252",
|
|
52
|
+
"700": "#404040",
|
|
53
|
+
"800": "#262626",
|
|
54
|
+
"900": "#171717",
|
|
55
|
+
"950": "#0a0a0a",
|
|
56
|
+
},
|
|
57
|
+
stone: {
|
|
58
|
+
"50": "#fafaf9",
|
|
59
|
+
"100": "#f5f5f4",
|
|
60
|
+
"200": "#e7e5e4",
|
|
61
|
+
"300": "#d6d3d1",
|
|
62
|
+
"400": "#a8a29e",
|
|
63
|
+
"500": "#78716c",
|
|
64
|
+
"600": "#57534e",
|
|
65
|
+
"700": "#44403c",
|
|
66
|
+
"800": "#292524",
|
|
67
|
+
"900": "#1c1917",
|
|
68
|
+
"950": "#0c0a09",
|
|
69
|
+
},
|
|
70
|
+
red: {
|
|
71
|
+
"50": "#fef2f2",
|
|
72
|
+
"100": "#fee2e2",
|
|
73
|
+
"200": "#fecaca",
|
|
74
|
+
"300": "#fca5a5",
|
|
75
|
+
"400": "#f87171",
|
|
76
|
+
"500": "#ef4444",
|
|
77
|
+
"600": "#dc2626",
|
|
78
|
+
"700": "#b91c1c",
|
|
79
|
+
"800": "#991b1b",
|
|
80
|
+
"900": "#7f1d1d",
|
|
81
|
+
"950": "#450a0a",
|
|
82
|
+
},
|
|
83
|
+
orange: {
|
|
84
|
+
"50": "#fff7ed",
|
|
85
|
+
"100": "#ffedd5",
|
|
86
|
+
"200": "#fed7aa",
|
|
87
|
+
"300": "#fdba74",
|
|
88
|
+
"400": "#fb923c",
|
|
89
|
+
"500": "#f97316",
|
|
90
|
+
"600": "#ea580c",
|
|
91
|
+
"700": "#c2410c",
|
|
92
|
+
"800": "#9a3412",
|
|
93
|
+
"900": "#7c2d12",
|
|
94
|
+
"950": "#431407",
|
|
95
|
+
},
|
|
96
|
+
amber: {
|
|
97
|
+
"50": "#fffbeb",
|
|
98
|
+
"100": "#fef3c7",
|
|
99
|
+
"200": "#fde68a",
|
|
100
|
+
"300": "#fcd34d",
|
|
101
|
+
"400": "#fbbf24",
|
|
102
|
+
"500": "#f59e0b",
|
|
103
|
+
"600": "#d97706",
|
|
104
|
+
"700": "#b45309",
|
|
105
|
+
"800": "#92400e",
|
|
106
|
+
"900": "#78350f",
|
|
107
|
+
"950": "#451a03",
|
|
108
|
+
},
|
|
109
|
+
yellow: {
|
|
110
|
+
"50": "#fefce8",
|
|
111
|
+
"100": "#fef9c3",
|
|
112
|
+
"200": "#fef08a",
|
|
113
|
+
"300": "#fde047",
|
|
114
|
+
"400": "#facc15",
|
|
115
|
+
"500": "#eab308",
|
|
116
|
+
"600": "#ca8a04",
|
|
117
|
+
"700": "#a16207",
|
|
118
|
+
"800": "#854d0e",
|
|
119
|
+
"900": "#713f12",
|
|
120
|
+
"950": "#422006",
|
|
121
|
+
},
|
|
122
|
+
lime: {
|
|
123
|
+
"50": "#f7fee7",
|
|
124
|
+
"100": "#ecfccb",
|
|
125
|
+
"200": "#d9f99d",
|
|
126
|
+
"300": "#bef264",
|
|
127
|
+
"400": "#a3e635",
|
|
128
|
+
"500": "#84cc16",
|
|
129
|
+
"600": "#65a30d",
|
|
130
|
+
"700": "#4d7c0f",
|
|
131
|
+
"800": "#3f6212",
|
|
132
|
+
"900": "#365314",
|
|
133
|
+
"950": "#1a2e05",
|
|
134
|
+
},
|
|
135
|
+
green: {
|
|
136
|
+
"50": "#f0fdf4",
|
|
137
|
+
"100": "#dcfce7",
|
|
138
|
+
"200": "#bbf7d0",
|
|
139
|
+
"300": "#86efac",
|
|
140
|
+
"400": "#4ade80",
|
|
141
|
+
"500": "#22c55e",
|
|
142
|
+
"600": "#16a34a",
|
|
143
|
+
"700": "#15803d",
|
|
144
|
+
"800": "#166534",
|
|
145
|
+
"900": "#14532d",
|
|
146
|
+
"950": "#052e16",
|
|
147
|
+
},
|
|
148
|
+
emerald: {
|
|
149
|
+
"50": "#ecfdf5",
|
|
150
|
+
"100": "#d1fae5",
|
|
151
|
+
"200": "#a7f3d0",
|
|
152
|
+
"300": "#6ee7b7",
|
|
153
|
+
"400": "#34d399",
|
|
154
|
+
"500": "#10b981",
|
|
155
|
+
"600": "#059669",
|
|
156
|
+
"700": "#047857",
|
|
157
|
+
"800": "#065f46",
|
|
158
|
+
"900": "#064e3b",
|
|
159
|
+
"950": "#022c22",
|
|
160
|
+
},
|
|
161
|
+
teal: {
|
|
162
|
+
"50": "#f0fdfa",
|
|
163
|
+
"100": "#ccfbf1",
|
|
164
|
+
"200": "#99f6e4",
|
|
165
|
+
"300": "#5eead4",
|
|
166
|
+
"400": "#2dd4bf",
|
|
167
|
+
"500": "#14b8a6",
|
|
168
|
+
"600": "#0d9488",
|
|
169
|
+
"700": "#0f766e",
|
|
170
|
+
"800": "#115e59",
|
|
171
|
+
"900": "#134e4a",
|
|
172
|
+
"950": "#042f2e",
|
|
173
|
+
},
|
|
174
|
+
cyan: {
|
|
175
|
+
"50": "#ecfeff",
|
|
176
|
+
"100": "#cffafe",
|
|
177
|
+
"200": "#a5f3fc",
|
|
178
|
+
"300": "#67e8f9",
|
|
179
|
+
"400": "#22d3ee",
|
|
180
|
+
"500": "#06b6d4",
|
|
181
|
+
"600": "#0891b2",
|
|
182
|
+
"700": "#0e7490",
|
|
183
|
+
"800": "#155e75",
|
|
184
|
+
"900": "#164e63",
|
|
185
|
+
"950": "#083344",
|
|
186
|
+
},
|
|
187
|
+
sky: {
|
|
188
|
+
"50": "#f0f9ff",
|
|
189
|
+
"100": "#e0f2fe",
|
|
190
|
+
"200": "#bae6fd",
|
|
191
|
+
"300": "#7dd3fc",
|
|
192
|
+
"400": "#38bdf8",
|
|
193
|
+
"500": "#0ea5e9",
|
|
194
|
+
"600": "#0284c7",
|
|
195
|
+
"700": "#0369a1",
|
|
196
|
+
"800": "#075985",
|
|
197
|
+
"900": "#0c4a6e",
|
|
198
|
+
"950": "#082f49",
|
|
199
|
+
},
|
|
200
|
+
blue: {
|
|
201
|
+
"50": "#eff6ff",
|
|
202
|
+
"100": "#dbeafe",
|
|
203
|
+
"200": "#bfdbfe",
|
|
204
|
+
"300": "#93c5fd",
|
|
205
|
+
"400": "#60a5fa",
|
|
206
|
+
"500": "#3b82f6",
|
|
207
|
+
"600": "#2563eb",
|
|
208
|
+
"700": "#1d4ed8",
|
|
209
|
+
"800": "#1e40af",
|
|
210
|
+
"900": "#1e3a8a",
|
|
211
|
+
"950": "#172554",
|
|
212
|
+
},
|
|
213
|
+
indigo: {
|
|
214
|
+
"50": "#eef2ff",
|
|
215
|
+
"100": "#e0e7ff",
|
|
216
|
+
"200": "#c7d2fe",
|
|
217
|
+
"300": "#a5b4fc",
|
|
218
|
+
"400": "#818cf8",
|
|
219
|
+
"500": "#6366f1",
|
|
220
|
+
"600": "#4f46e5",
|
|
221
|
+
"700": "#4338ca",
|
|
222
|
+
"800": "#3730a3",
|
|
223
|
+
"900": "#312e81",
|
|
224
|
+
"950": "#1e1b4b",
|
|
225
|
+
},
|
|
226
|
+
violet: {
|
|
227
|
+
"50": "#f5f3ff",
|
|
228
|
+
"100": "#ede9fe",
|
|
229
|
+
"200": "#ddd6fe",
|
|
230
|
+
"300": "#c4b5fd",
|
|
231
|
+
"400": "#a78bfa",
|
|
232
|
+
"500": "#8b5cf6",
|
|
233
|
+
"600": "#7c3aed",
|
|
234
|
+
"700": "#6d28d9",
|
|
235
|
+
"800": "#5b21b6",
|
|
236
|
+
"900": "#4c1d95",
|
|
237
|
+
"950": "#2e1065",
|
|
238
|
+
},
|
|
239
|
+
purple: {
|
|
240
|
+
"50": "#faf5ff",
|
|
241
|
+
"100": "#f3e8ff",
|
|
242
|
+
"200": "#e9d5ff",
|
|
243
|
+
"300": "#d8b4fe",
|
|
244
|
+
"400": "#c084fc",
|
|
245
|
+
"500": "#a855f7",
|
|
246
|
+
"600": "#9333ea",
|
|
247
|
+
"700": "#7e22ce",
|
|
248
|
+
"800": "#6b21a8",
|
|
249
|
+
"900": "#581c87",
|
|
250
|
+
"950": "#3b0764",
|
|
251
|
+
},
|
|
252
|
+
fuchsia: {
|
|
253
|
+
"50": "#fdf4ff",
|
|
254
|
+
"100": "#fae8ff",
|
|
255
|
+
"200": "#f5d0fe",
|
|
256
|
+
"300": "#f0abfc",
|
|
257
|
+
"400": "#e879f9",
|
|
258
|
+
"500": "#d946ef",
|
|
259
|
+
"600": "#c026d3",
|
|
260
|
+
"700": "#a21caf",
|
|
261
|
+
"800": "#86198f",
|
|
262
|
+
"900": "#701a75",
|
|
263
|
+
"950": "#4a044e",
|
|
264
|
+
},
|
|
265
|
+
pink: {
|
|
266
|
+
"50": "#fdf2f8",
|
|
267
|
+
"100": "#fce7f3",
|
|
268
|
+
"200": "#fbcfe8",
|
|
269
|
+
"300": "#f9a8d4",
|
|
270
|
+
"400": "#f472b6",
|
|
271
|
+
"500": "#ec4899",
|
|
272
|
+
"600": "#db2777",
|
|
273
|
+
"700": "#be185d",
|
|
274
|
+
"800": "#9d174d",
|
|
275
|
+
"900": "#831843",
|
|
276
|
+
"950": "#500724",
|
|
277
|
+
},
|
|
278
|
+
rose: {
|
|
279
|
+
"50": "#fff1f2",
|
|
280
|
+
"100": "#ffe4e6",
|
|
281
|
+
"200": "#fecdd3",
|
|
282
|
+
"300": "#fda4af",
|
|
283
|
+
"400": "#fb7185",
|
|
284
|
+
"500": "#f43f5e",
|
|
285
|
+
"600": "#e11d48",
|
|
286
|
+
"700": "#be123c",
|
|
287
|
+
"800": "#9f1239",
|
|
288
|
+
"900": "#881337",
|
|
289
|
+
"950": "#4c0519",
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
// null entries are intentionally unresolvable: 'transparent' and 'current'
|
|
293
|
+
// have no fixed luminance to compute a ratio against.
|
|
294
|
+
export const semanticColors = {
|
|
295
|
+
white: "#ffffff",
|
|
296
|
+
black: "#000000",
|
|
297
|
+
transparent: null,
|
|
298
|
+
current: null,
|
|
299
|
+
inherit: null,
|
|
300
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const spacingScale: Record<string, number>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Snapshot of tailwindcss/defaultTheme's `spacing` scale, converted to px at
|
|
2
|
+
// the standard 16px root (rem values × 16), generated the same way
|
|
3
|
+
// defaultPalette.ts was — verified against the real installed `tailwindcss`
|
|
4
|
+
// package's `require('tailwindcss/defaultTheme').spacing` rather than
|
|
5
|
+
// hand-transcribed. See CLAUDE.md: v1 does not read a user's tailwind.config.
|
|
6
|
+
//
|
|
7
|
+
// No `/`-fraction keys exist in this base scale (fractions like `w-1/2` are
|
|
8
|
+
// merged in separately by Tailwind's width/height theme functions), so a
|
|
9
|
+
// fraction class naturally fails this lookup and is skipped as unresolvable
|
|
10
|
+
// — the desired behavior, since a fraction is relative, not a fixed px value.
|
|
11
|
+
export const spacingScale = {
|
|
12
|
+
"0": 0,
|
|
13
|
+
"px": 1,
|
|
14
|
+
"0.5": 2,
|
|
15
|
+
"1": 4,
|
|
16
|
+
"1.5": 6,
|
|
17
|
+
"2": 8,
|
|
18
|
+
"2.5": 10,
|
|
19
|
+
"3": 12,
|
|
20
|
+
"3.5": 14,
|
|
21
|
+
"4": 16,
|
|
22
|
+
"5": 20,
|
|
23
|
+
"6": 24,
|
|
24
|
+
"7": 28,
|
|
25
|
+
"8": 32,
|
|
26
|
+
"9": 36,
|
|
27
|
+
"10": 40,
|
|
28
|
+
"11": 44,
|
|
29
|
+
"12": 48,
|
|
30
|
+
"14": 56,
|
|
31
|
+
"16": 64,
|
|
32
|
+
"20": 80,
|
|
33
|
+
"24": 96,
|
|
34
|
+
"28": 112,
|
|
35
|
+
"32": 128,
|
|
36
|
+
"36": 144,
|
|
37
|
+
"40": 160,
|
|
38
|
+
"44": 176,
|
|
39
|
+
"48": 192,
|
|
40
|
+
"52": 208,
|
|
41
|
+
"56": 224,
|
|
42
|
+
"60": 240,
|
|
43
|
+
"64": 256,
|
|
44
|
+
"72": 288,
|
|
45
|
+
"80": 320,
|
|
46
|
+
"96": 384,
|
|
47
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tailwind-a11y",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tailwind-a11y": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": ["dist"],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"dev": "tsx src/cli.ts",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"prepublishOnly": "npm run build && npm test"
|
|
27
|
+
},
|
|
28
|
+
"keywords": ["tailwindcss", "accessibility", "a11y", "wcag", "wcag22", "contrast", "touch-target", "focus-visible", "lint"],
|
|
29
|
+
"author": "chamroro",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/chamroro/tailwind-a11y.git",
|
|
34
|
+
"directory": "packages/tailwind-a11y"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/chamroro/tailwind-a11y/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/chamroro/tailwind-a11y/tree/main/packages/tailwind-a11y#readme",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@babel/parser": "^7.25.0",
|
|
42
|
+
"@babel/traverse": "^7.25.0",
|
|
43
|
+
"@babel/types": "^7.25.0",
|
|
44
|
+
"fast-glob": "^3.3.2"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/babel__traverse": "^7.20.6",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"tsx": "^4.19.0",
|
|
50
|
+
"typescript": "^5.5.0",
|
|
51
|
+
"vitest": "^2.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|