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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }
@@ -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,7 @@
1
+ export interface FocusIndicatorCheck {
2
+ file: string;
3
+ line: number;
4
+ tagName: string;
5
+ focusClasses: string[];
6
+ }
7
+ export declare function extractFocusIndicatorChecks(code: string, filePath: string): FocusIndicatorCheck[];
@@ -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,2 @@
1
+ import * as t from "@babel/types";
2
+ export declare function isInteractiveElement(opening: t.JSXOpeningElement): boolean;
@@ -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,4 @@
1
+ export type ColorScale = Record<string, string>;
2
+ export type Palette = Record<string, ColorScale>;
3
+ export declare const defaultPalette: Palette;
4
+ export declare const semanticColors: Record<string, string | null>;
@@ -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
+ }