tailwind-a11y 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/cli.js +17 -4
- package/dist/cliArgs.d.ts +2 -0
- package/dist/cliArgs.js +32 -4
- package/dist/contrast/luminance.d.ts +1 -0
- package/dist/contrast/luminance.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/parser/extractClasses.js +7 -2
- package/dist/parser/extractTouchTargets.d.ts +2 -2
- package/dist/parser/extractTouchTargets.js +7 -7
- package/dist/rules/checkContrast.d.ts +5 -4
- package/dist/rules/checkContrast.js +107 -39
- package/dist/theme/loadCustomTheme.d.ts +18 -0
- package/dist/theme/loadCustomTheme.js +149 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,13 +22,19 @@ npm install --save-dev tailwind-a11y
|
|
|
22
22
|
## Usage
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
npx tailwind-a11y
|
|
26
|
-
npx tailwind-a11y "src/**/*.tsx"
|
|
27
|
-
npx tailwind-a11y --verbose
|
|
28
|
-
npx tailwind-a11y --
|
|
29
|
-
npx tailwind-a11y --
|
|
25
|
+
npx tailwind-a11y # scans **/*.{jsx,tsx}
|
|
26
|
+
npx tailwind-a11y "src/**/*.tsx" # custom glob
|
|
27
|
+
npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
|
|
28
|
+
npx tailwind-a11y --config ./tw.config.cjs # use a specific tailwind.config instead of auto-detecting
|
|
29
|
+
npx tailwind-a11y --version # print the installed version
|
|
30
|
+
npx tailwind-a11y --help # usage and all options
|
|
30
31
|
```
|
|
31
32
|
|
|
33
|
+
Custom `theme.extend.colors`/`theme.extend.spacing` in a `tailwind.config.js`/`.cjs`
|
|
34
|
+
found in the current directory (or passed via `--config`) are read automatically, so
|
|
35
|
+
colors and spacing outside Tailwind's defaults resolve too — not just the built-in
|
|
36
|
+
palette.
|
|
37
|
+
|
|
32
38
|
```
|
|
33
39
|
src/components/Card.tsx
|
|
34
40
|
3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA); try text-gray-500 (4.83)
|
|
@@ -44,7 +50,7 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
44
50
|
|
|
45
51
|
| Check | WCAG | Detects |
|
|
46
52
|
|---|---|---|
|
|
47
|
-
| Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent; suggests the nearest passing shade |
|
|
53
|
+
| Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent, including a text-side opacity modifier (`text-gray-400/50`) composited against the background; suggests the nearest passing shade |
|
|
48
54
|
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px |
|
|
49
55
|
| Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
|
|
50
56
|
|
|
@@ -54,8 +60,13 @@ When a case can't be resolved with confidence, it's skipped rather than guessed:
|
|
|
54
60
|
|
|
55
61
|
- Ancestors beyond the immediate parent, or backgrounds set inside a separate component
|
|
56
62
|
- Dynamic or computed `className` (ternaries, `clsx()`, template literals)
|
|
57
|
-
-
|
|
58
|
-
|
|
63
|
+
- A full `theme.colors`/`theme.spacing` replacement, `.mjs`/`.ts` configs, or
|
|
64
|
+
Tailwind v4's CSS-based `@theme` config (only `theme.extend` in a `.js`/`.cjs`
|
|
65
|
+
config is read — see [CLAUDE.md](./CLAUDE.md))
|
|
66
|
+
- Opacity shorthand on the **background** side (`bg-white/50` as the actual
|
|
67
|
+
background) — the rendered backdrop is layout-dependent and out of scope,
|
|
68
|
+
same reasoning as the ancestor-parent limit above. **Text-side** opacity
|
|
69
|
+
(`text-gray-400/50`) *is* resolved, composited against the (opaque) background.
|
|
59
70
|
- Frameworks other than React/JSX
|
|
60
71
|
|
|
61
72
|
`--verbose` reports what was skipped and why — a skip is not a pass. Full rationale in
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { relative, sep } from "node:path";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import fg from "fast-glob";
|
|
6
6
|
import { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
|
|
@@ -10,6 +10,7 @@ import { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
|
10
10
|
import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
11
11
|
import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
12
12
|
import { parseArgs, getHelpText } from "./cliArgs.js";
|
|
13
|
+
import { resolveTheme } from "./theme/loadCustomTheme.js";
|
|
13
14
|
// ../package.json resolves correctly from both src/ (dev) and dist/ (published).
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const { version: packageVersion } = require("../package.json");
|
|
@@ -39,7 +40,7 @@ function groupByFile(items) {
|
|
|
39
40
|
return byFile;
|
|
40
41
|
}
|
|
41
42
|
async function main() {
|
|
42
|
-
const { help, version, verbose, patterns } = parseArgs(process.argv.slice(2));
|
|
43
|
+
const { help, version, verbose, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
|
|
43
44
|
if (help) {
|
|
44
45
|
console.log(getHelpText());
|
|
45
46
|
return;
|
|
@@ -48,6 +49,18 @@ async function main() {
|
|
|
48
49
|
console.log(packageVersion);
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
52
|
+
if (usageError) {
|
|
53
|
+
console.error(`tailwind-a11y: ${usageError}`);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { palette, spacing, configError } = resolveTheme({
|
|
58
|
+
rootDir: process.cwd(),
|
|
59
|
+
configPath: config ? resolve(process.cwd(), config) : null,
|
|
60
|
+
});
|
|
61
|
+
if (configError) {
|
|
62
|
+
console.warn(`tailwind-a11y: ${configError}`);
|
|
63
|
+
}
|
|
51
64
|
const globPatterns = patterns.length > 0 ? patterns : ["**/*.{jsx,tsx}"];
|
|
52
65
|
const files = await fg(globPatterns, {
|
|
53
66
|
cwd: process.cwd(),
|
|
@@ -64,9 +77,9 @@ async function main() {
|
|
|
64
77
|
try {
|
|
65
78
|
const code = readFileSync(absPath, "utf8");
|
|
66
79
|
const contrastChecks = extractChecks(code, file);
|
|
67
|
-
violations.push(...checkContrast(contrastChecks), ...checkTouchTargets(extractTouchTargetChecks(code, file)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
80
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
68
81
|
if (verbose) {
|
|
69
|
-
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks), ...extractTouchTargetSkips(code, file));
|
|
82
|
+
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
catch (err) {
|
package/dist/cliArgs.d.ts
CHANGED
package/dist/cliArgs.js
CHANGED
|
@@ -6,14 +6,18 @@ Static analysis for Tailwind CSS accessibility violations -- color contrast,
|
|
|
6
6
|
touch target size, and focus indicator removal.
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
|
-
-v, --verbose
|
|
10
|
-
-V, --version
|
|
11
|
-
-h, --help
|
|
9
|
+
-v, --verbose Also report what couldn't be checked, and why
|
|
10
|
+
-V, --version Print the version number
|
|
11
|
+
-h, --help Print this help message
|
|
12
|
+
--config <path> Path to a tailwind.config.js/.cjs to read custom
|
|
13
|
+
theme colors/spacing from (default: auto-detected
|
|
14
|
+
in the current directory)
|
|
12
15
|
|
|
13
16
|
Examples:
|
|
14
17
|
tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
|
|
15
18
|
tailwind-a11y "src/**/*.tsx" Scan a custom glob pattern
|
|
16
19
|
tailwind-a11y --verbose Also report skipped/unresolvable cases
|
|
20
|
+
tailwind-a11y --config ./tailwind.config.cjs
|
|
17
21
|
`;
|
|
18
22
|
export function getHelpText() {
|
|
19
23
|
return HELP_TEXT;
|
|
@@ -22,10 +26,34 @@ export function getHelpText() {
|
|
|
22
26
|
// avoids colliding with it, matching a common CLI convention.
|
|
23
27
|
const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h"]);
|
|
24
28
|
export function parseArgs(argv) {
|
|
29
|
+
let config = null;
|
|
30
|
+
let configError = null;
|
|
31
|
+
const patterns = [];
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const arg = argv[i];
|
|
34
|
+
if (arg !== "--config") {
|
|
35
|
+
if (!FLAGS.has(arg))
|
|
36
|
+
patterns.push(arg);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const value = argv[i + 1];
|
|
40
|
+
// A missing value or one that looks like another flag (starts with "-")
|
|
41
|
+
// must not be silently swallowed as a bogus path -- report a usage error
|
|
42
|
+
// instead of guessing.
|
|
43
|
+
if (value === undefined || value.startsWith("-")) {
|
|
44
|
+
configError = "--config requires a path argument";
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
config = value;
|
|
48
|
+
i++; // consume the value too, so it isn't also treated as a glob pattern
|
|
49
|
+
}
|
|
50
|
+
}
|
|
25
51
|
return {
|
|
26
52
|
help: argv.includes("--help") || argv.includes("-h"),
|
|
27
53
|
version: argv.includes("--version") || argv.includes("-V"),
|
|
28
54
|
verbose: argv.includes("--verbose") || argv.includes("-v"),
|
|
29
|
-
|
|
55
|
+
config,
|
|
56
|
+
configError,
|
|
57
|
+
patterns,
|
|
30
58
|
};
|
|
31
59
|
}
|
|
@@ -4,6 +4,7 @@ export interface RGB {
|
|
|
4
4
|
b: number;
|
|
5
5
|
}
|
|
6
6
|
export declare function hexToRgb(hex: string): RGB | null;
|
|
7
|
+
export declare function applyAlpha(fg: RGB, alpha: number, bg: RGB): RGB;
|
|
7
8
|
export declare function relativeLuminance(rgb: RGB): number;
|
|
8
9
|
export declare function contrastRatio(rgb1: RGB, rgb2: RGB): number;
|
|
9
10
|
export declare function requiredRatio(level: "AA" | "AAA", isLargeText: boolean): number;
|
|
@@ -16,6 +16,17 @@ export function hexToRgb(hex) {
|
|
|
16
16
|
b: parseInt(digits.slice(4, 6), 16),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
// Standard "src-over" compositing of a foreground at `alpha` opacity over an
|
|
20
|
+
// opaque background, in gamma-encoded sRGB space (0-255 channels) -- matches
|
|
21
|
+
// how browsers actually composite CSS opacity, no linear-light conversion
|
|
22
|
+
// needed for a simple two-layer blend.
|
|
23
|
+
export function applyAlpha(fg, alpha, bg) {
|
|
24
|
+
return {
|
|
25
|
+
r: Math.round(alpha * fg.r + (1 - alpha) * bg.r),
|
|
26
|
+
g: Math.round(alpha * fg.g + (1 - alpha) * bg.g),
|
|
27
|
+
b: Math.round(alpha * fg.b + (1 - alpha) * bg.b),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
19
30
|
function channelLuminance(channel) {
|
|
20
31
|
const c = channel / 255;
|
|
21
32
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouch
|
|
|
5
5
|
export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
|
|
6
6
|
export { checkFocusIndicators, type FocusIndicatorViolation } from "./rules/checkFocusIndicator.js";
|
|
7
7
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
|
|
8
|
+
export { resolveTheme, findTailwindConfig, loadCustomTheme, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
|
|
9
|
+
export type { Palette, ColorScale } from "./theme/defaultPalette.js";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
|
5
5
|
export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
6
6
|
export { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
7
7
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
|
|
8
|
+
export { resolveTheme, findTailwindConfig, loadCustomTheme, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
|
|
@@ -3,8 +3,13 @@ import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
|
3
3
|
// Positive shape filter for "does this look like a color utility", not a
|
|
4
4
|
// blocklist — Tailwind heavily overloads the text-*/bg-* prefix (text-lg,
|
|
5
5
|
// bg-cover, bg-gradient-to-r, ...) and an exclude-list would be fragile
|
|
6
|
-
// across versions.
|
|
7
|
-
|
|
6
|
+
// across versions. Every alternative allows an optional trailing opacity
|
|
7
|
+
// modifier (/NN) so text-white/40, text-[#eee]/40, and text-gray-400/40 are
|
|
8
|
+
// all extracted with the suffix intact -- text-white/black-with-opacity is
|
|
9
|
+
// an extremely common real idiom, more so than the named-scale case, and
|
|
10
|
+
// dropping it here would make the contrast checker's opacity support (see
|
|
11
|
+
// rules/checkContrast.ts) silently inapplicable to the most common case.
|
|
12
|
+
const COLOR_TOKEN = /^\[(#[0-9a-fA-F]{3,8})\](\/\d{1,3})?$|^[a-z]+-\d{2,3}(\/\d{1,3})?$|^(white|black|transparent|current|inherit)(\/\d{1,3})?$/;
|
|
8
13
|
// opacity-{N} (e.g. bg-opacity-50, text-opacity-50) matches the same
|
|
9
14
|
// "word-number" shape as a color token but isn't one — without this
|
|
10
15
|
// exclusion it can silently overwrite a real color match via the
|
|
@@ -7,10 +7,10 @@ export interface TouchTargetCheck {
|
|
|
7
7
|
widthPx: number;
|
|
8
8
|
heightPx: number;
|
|
9
9
|
}
|
|
10
|
-
export declare function extractTouchTargetChecks(code: string, filePath: string): TouchTargetCheck[];
|
|
10
|
+
export declare function extractTouchTargetChecks(code: string, filePath: string, spacing?: Record<string, number>): TouchTargetCheck[];
|
|
11
11
|
export interface TouchTargetSkip {
|
|
12
12
|
file: string;
|
|
13
13
|
line: number;
|
|
14
14
|
reason: string;
|
|
15
15
|
}
|
|
16
|
-
export declare function extractTouchTargetSkips(code: string, filePath: string): TouchTargetSkip[];
|
|
16
|
+
export declare function extractTouchTargetSkips(code: string, filePath: string, spacing?: Record<string, number>): TouchTargetSkip[];
|
|
@@ -42,7 +42,7 @@ function isInlineInText(path) {
|
|
|
42
42
|
return false;
|
|
43
43
|
return isMeaningfulText(siblings[index - 1]) || isMeaningfulText(siblings[index + 1]);
|
|
44
44
|
}
|
|
45
|
-
export function extractTouchTargetChecks(code, filePath) {
|
|
45
|
+
export function extractTouchTargetChecks(code, filePath, spacing = spacingScale) {
|
|
46
46
|
const ast = parseJSX(code, filePath);
|
|
47
47
|
if (!ast)
|
|
48
48
|
return [];
|
|
@@ -60,8 +60,8 @@ export function extractTouchTargetChecks(code, filePath) {
|
|
|
60
60
|
const height = lastSizeToken(tokens, "h");
|
|
61
61
|
if (!width || !height)
|
|
62
62
|
return; // either dimension missing/dynamic — skip, don't guess
|
|
63
|
-
const widthPx =
|
|
64
|
-
const heightPx =
|
|
63
|
+
const widthPx = spacing[width.value];
|
|
64
|
+
const heightPx = spacing[height.value];
|
|
65
65
|
if (widthPx === undefined || heightPx === undefined)
|
|
66
66
|
return; // arbitrary/keyword/fraction — skip
|
|
67
67
|
if (isInlineInText(path))
|
|
@@ -85,7 +85,7 @@ export function extractTouchTargetChecks(code, filePath) {
|
|
|
85
85
|
// fraction). Elements with neither w-* nor h-* at all aren't reported —
|
|
86
86
|
// that's the overwhelming majority of interactive elements and would be
|
|
87
87
|
// pure noise, not a meaningful skip.
|
|
88
|
-
export function extractTouchTargetSkips(code, filePath) {
|
|
88
|
+
export function extractTouchTargetSkips(code, filePath, spacing = spacingScale) {
|
|
89
89
|
const ast = parseJSX(code, filePath);
|
|
90
90
|
if (!ast)
|
|
91
91
|
return [];
|
|
@@ -110,11 +110,11 @@ export function extractTouchTargetSkips(code, filePath) {
|
|
|
110
110
|
skips.push({ file: filePath, line, reason: `${found.raw} present but no ${missing} utility set — skipped` });
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
|
-
const widthPx =
|
|
114
|
-
const heightPx =
|
|
113
|
+
const widthPx = spacing[width.value];
|
|
114
|
+
const heightPx = spacing[height.value];
|
|
115
115
|
if (widthPx === undefined || heightPx === undefined) {
|
|
116
116
|
const bad = widthPx === undefined ? width.raw : height.raw;
|
|
117
|
-
skips.push({ file: filePath, line, reason: `${bad} is not in the
|
|
117
|
+
skips.push({ file: filePath, line, reason: `${bad} is not in the resolved spacing scale (arbitrary, keyword, or fraction value) — skipped` });
|
|
118
118
|
}
|
|
119
119
|
},
|
|
120
120
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Palette } from "../theme/defaultPalette.js";
|
|
1
2
|
import type { ContrastCheck } from "../parser/extractClasses.js";
|
|
2
3
|
export interface ContrastViolation {
|
|
3
4
|
type: "contrast";
|
|
@@ -11,16 +12,16 @@ export interface ContrastViolation {
|
|
|
11
12
|
suggestion?: string;
|
|
12
13
|
suggestedRatio?: number;
|
|
13
14
|
}
|
|
14
|
-
export declare function resolveColorValue(utilityClass: string): string | null;
|
|
15
|
-
export declare function checkContrast(checks: ContrastCheck[]): ContrastViolation[];
|
|
15
|
+
export declare function resolveColorValue(utilityClass: string, palette?: Palette): string | null;
|
|
16
|
+
export declare function checkContrast(checks: ContrastCheck[], palette?: Palette): ContrastViolation[];
|
|
16
17
|
export interface ContrastFix {
|
|
17
18
|
textClass: string;
|
|
18
19
|
ratio: number;
|
|
19
20
|
}
|
|
20
|
-
export declare function suggestContrastFix(textClass: string, bgClass: string, required: number): ContrastFix | null;
|
|
21
|
+
export declare function suggestContrastFix(textClass: string, bgClass: string, required: number, palette?: Palette): ContrastFix | null;
|
|
21
22
|
export interface ContrastValueSkip {
|
|
22
23
|
file: string;
|
|
23
24
|
line: number;
|
|
24
25
|
reason: string;
|
|
25
26
|
}
|
|
26
|
-
export declare function checkContrastValueSkips(checks: ContrastCheck[]): ContrastValueSkip[];
|
|
27
|
+
export declare function checkContrastValueSkips(checks: ContrastCheck[], palette?: Palette): ContrastValueSkip[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
|
|
1
|
+
import { applyAlpha, contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
|
|
2
2
|
import { defaultPalette, semanticColors } from "../theme/defaultPalette.js";
|
|
3
|
-
export function resolveColorValue(utilityClass) {
|
|
3
|
+
export function resolveColorValue(utilityClass, palette = defaultPalette) {
|
|
4
4
|
const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
|
|
5
5
|
if (!match)
|
|
6
6
|
return null;
|
|
@@ -17,23 +17,57 @@ export function resolveColorValue(utilityClass) {
|
|
|
17
17
|
const [scale, shade] = token.split("-");
|
|
18
18
|
if (!scale || !shade)
|
|
19
19
|
return null;
|
|
20
|
-
return
|
|
20
|
+
return palette[scale]?.[shade] ?? null; // unknown/custom color — skip
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
// Splits a trailing Tailwind opacity modifier off a color utility class
|
|
23
|
+
// (e.g. "text-gray-400/50" -> { base: "text-gray-400", alpha: 0.5 }). No
|
|
24
|
+
// modifier -> alpha 1. Out-of-range (>100) is clamped rather than rejected --
|
|
25
|
+
// real browsers clamp out-of-range CSS alpha to the nearest valid bound, so
|
|
26
|
+
// "/150" genuinely renders identically to "/100"; silently skipping it
|
|
27
|
+
// instead would hide a real violation behind what's essentially a typo. A
|
|
28
|
+
// non-digit or malformed suffix falls through as { base: <the whole original
|
|
29
|
+
// string>, alpha: 1 } -- base still contains the "/", so resolveColorValue's
|
|
30
|
+
// existing guard rejects it downstream; this never needs to return null.
|
|
31
|
+
function splitOpacityModifier(utilityClass) {
|
|
32
|
+
const match = /^(.+)\/(\d{1,3})$/.exec(utilityClass);
|
|
33
|
+
if (!match)
|
|
34
|
+
return { base: utilityClass, alpha: 1 };
|
|
35
|
+
const pct = Math.min(100, Math.max(0, Number(match[2])));
|
|
36
|
+
return { base: match[1], alpha: pct / 100 };
|
|
37
|
+
}
|
|
38
|
+
// Resolves a text-* class (with or without an opacity modifier) to the
|
|
39
|
+
// effective color it actually renders as, composited against the already-
|
|
40
|
+
// resolved (fully opaque) background. Only the text side supports opacity --
|
|
41
|
+
// see resolveColorValue's own unconditional "/" rejection for why the
|
|
42
|
+
// background side doesn't: compositing a semi-transparent background
|
|
43
|
+
// correctly requires knowing what's rendered *behind* it, which is out of
|
|
44
|
+
// scope the same way walking further up the ancestor chain is.
|
|
45
|
+
function resolveTextColorWithOpacity(utilityClass, bgRgb, palette) {
|
|
46
|
+
const { base, alpha } = splitOpacityModifier(utilityClass);
|
|
47
|
+
if (alpha === 0)
|
|
48
|
+
return null; // fully transparent -- equivalent to text-transparent, nothing to check
|
|
49
|
+
const hex = resolveColorValue(base, palette);
|
|
50
|
+
const rgb = hex ? hexToRgb(hex) : null;
|
|
51
|
+
if (!rgb)
|
|
52
|
+
return null;
|
|
53
|
+
return alpha < 1 ? applyAlpha(rgb, alpha, bgRgb) : rgb;
|
|
54
|
+
}
|
|
55
|
+
export function checkContrast(checks, palette = defaultPalette) {
|
|
23
56
|
const violations = [];
|
|
24
57
|
for (const check of checks) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
if (!textHex || !bgHex)
|
|
58
|
+
const bgHex = resolveColorValue(check.bgColorClass, palette);
|
|
59
|
+
if (!bgHex)
|
|
28
60
|
continue;
|
|
29
|
-
const textRgb = hexToRgb(textHex);
|
|
30
61
|
const bgRgb = hexToRgb(bgHex);
|
|
31
|
-
if (!
|
|
62
|
+
if (!bgRgb)
|
|
63
|
+
continue;
|
|
64
|
+
const textRgb = resolveTextColorWithOpacity(check.textColorClass, bgRgb, palette);
|
|
65
|
+
if (!textRgb)
|
|
32
66
|
continue;
|
|
33
67
|
const ratio = contrastRatio(textRgb, bgRgb);
|
|
34
68
|
const required = requiredRatio("AA", false); // v1: large-text detection deferred
|
|
35
69
|
if (!meetsWCAG(ratio, "AA", false)) {
|
|
36
|
-
const fix = suggestContrastFix(check.textColorClass, check.bgColorClass, required);
|
|
70
|
+
const fix = suggestContrastFix(check.textColorClass, check.bgColorClass, required, palette);
|
|
37
71
|
violations.push({
|
|
38
72
|
type: "contrast",
|
|
39
73
|
file: check.file,
|
|
@@ -50,23 +84,27 @@ export function checkContrast(checks) {
|
|
|
50
84
|
return violations;
|
|
51
85
|
}
|
|
52
86
|
const TEXT_SCALE_SHADE_RE = /^text-([a-z]+)-(\d+)$/;
|
|
53
|
-
// Only the text shade moves — bg
|
|
54
|
-
//
|
|
55
|
-
// actual keys (not an assumed
|
|
56
|
-
//
|
|
57
|
-
// shade
|
|
58
|
-
// darker is the fix a human
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
// Only the text shade moves — bg and any opacity modifier on the text class
|
|
88
|
+
// stay fixed, since text color is the more commonly adjustable side in
|
|
89
|
+
// practice. Candidates come from the palette's actual keys (not an assumed
|
|
90
|
+
// 50..950 enumeration), sorted nearest-first by numeric distance from the
|
|
91
|
+
// original shade; ties favor the higher/darker shade, since real failures
|
|
92
|
+
// here are overwhelmingly light-on-light and darker is the fix a human
|
|
93
|
+
// reaches for. The original shade can never win: it's in this same
|
|
94
|
+
// candidate list at distance 0, and this recomputes the identical unrounded
|
|
95
|
+
// ratio comparison that just failed.
|
|
96
|
+
export function suggestContrastFix(textClass, bgClass, required, palette = defaultPalette) {
|
|
97
|
+
const { base, alpha } = splitOpacityModifier(textClass);
|
|
98
|
+
if (alpha === 0)
|
|
99
|
+
return null; // no shade change fixes total transparency
|
|
100
|
+
const match = TEXT_SCALE_SHADE_RE.exec(base);
|
|
63
101
|
if (!match)
|
|
64
|
-
return null; // text-white, text-[#eee]
|
|
102
|
+
return null; // text-white, text-[#eee] — no suggestion
|
|
65
103
|
const [, scale, shade] = match;
|
|
66
|
-
const shades =
|
|
104
|
+
const shades = palette[scale];
|
|
67
105
|
if (!shades?.[shade])
|
|
68
106
|
return null; // custom scale, or a decoy like text-opacity-50
|
|
69
|
-
const bgHex = resolveColorValue(bgClass);
|
|
107
|
+
const bgHex = resolveColorValue(bgClass, palette);
|
|
70
108
|
const bgRgb = bgHex ? hexToRgb(bgHex) : null;
|
|
71
109
|
if (!bgRgb)
|
|
72
110
|
return null;
|
|
@@ -75,34 +113,64 @@ export function suggestContrastFix(textClass, bgClass, required) {
|
|
|
75
113
|
.filter((s) => /^\d+$/.test(s))
|
|
76
114
|
.map(Number)
|
|
77
115
|
.sort((a, b) => Math.abs(a - original) - Math.abs(b - original) || b - a);
|
|
116
|
+
// Only the shade searches candidates -- the original opacity is held
|
|
117
|
+
// fixed, exactly like bg is already held fixed.
|
|
78
118
|
for (const candidate of candidates) {
|
|
79
119
|
const rgb = hexToRgb(shades[String(candidate)]);
|
|
80
120
|
if (!rgb)
|
|
81
121
|
continue;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
122
|
+
const effectiveRgb = alpha < 1 ? applyAlpha(rgb, alpha, bgRgb) : rgb;
|
|
123
|
+
const ratio = contrastRatio(effectiveRgb, bgRgb);
|
|
124
|
+
if (ratio >= required) {
|
|
125
|
+
const suggestedClass = alpha < 1 ? `text-${scale}-${candidate}/${Math.round(alpha * 100)}` : `text-${scale}-${candidate}`;
|
|
126
|
+
return { textClass: suggestedClass, ratio };
|
|
127
|
+
}
|
|
85
128
|
}
|
|
86
129
|
return null;
|
|
87
130
|
}
|
|
88
131
|
// A candidate that extractChecks *did* find a background for, but whose
|
|
89
132
|
// text or bg utility didn't resolve to a known value (custom theme color,
|
|
90
|
-
// non-hex arbitrary value, opacity shorthand) — surfaced
|
|
91
|
-
// extractContrastSkips' component-boundary case, since this
|
|
92
|
-
// a full text/bg pair and only failed at value resolution.
|
|
93
|
-
|
|
133
|
+
// non-hex arbitrary value, background-side opacity shorthand) — surfaced
|
|
134
|
+
// separately from extractContrastSkips' component-boundary case, since this
|
|
135
|
+
// one already has a full text/bg pair and only failed at value resolution.
|
|
136
|
+
//
|
|
137
|
+
// Resolves bg first, same order as checkContrast, so a bg-side failure and a
|
|
138
|
+
// text-side failure are attributed to the correct class -- since text
|
|
139
|
+
// resolution now depends on a known bg to composite against, resolving both
|
|
140
|
+
// independently (as before) would make every bg failure also look like a
|
|
141
|
+
// text failure. This does mean that when *both* sides are unresolvable for
|
|
142
|
+
// unrelated reasons, bg is now named first (previously text always was) --
|
|
143
|
+
// a deliberate, tested change, not an accidental side effect of reordering.
|
|
144
|
+
export function checkContrastValueSkips(checks, palette = defaultPalette) {
|
|
94
145
|
const skips = [];
|
|
95
146
|
for (const check of checks) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
147
|
+
const bgHex = resolveColorValue(check.bgColorClass, palette);
|
|
148
|
+
const bgRgb = bgHex ? hexToRgb(bgHex) : null;
|
|
149
|
+
if (!bgRgb) {
|
|
150
|
+
skips.push({
|
|
151
|
+
file: check.file,
|
|
152
|
+
line: check.line,
|
|
153
|
+
reason: `${check.bgColorClass} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`,
|
|
154
|
+
});
|
|
99
155
|
continue;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
156
|
+
}
|
|
157
|
+
const { alpha } = splitOpacityModifier(check.textColorClass);
|
|
158
|
+
if (alpha === 0) {
|
|
159
|
+
skips.push({
|
|
160
|
+
file: check.file,
|
|
161
|
+
line: check.line,
|
|
162
|
+
reason: `${check.textColorClass} is fully transparent (opacity 0) — nothing rendered to check`,
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const textRgb = resolveTextColorWithOpacity(check.textColorClass, bgRgb, palette);
|
|
167
|
+
if (!textRgb) {
|
|
168
|
+
skips.push({
|
|
169
|
+
file: check.file,
|
|
170
|
+
line: check.line,
|
|
171
|
+
reason: `${check.textColorClass} is not a recognized color (custom theme color or unsupported arbitrary value) — skipped`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
106
174
|
}
|
|
107
175
|
return skips;
|
|
108
176
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Palette } from "./defaultPalette.js";
|
|
2
|
+
export declare function findTailwindConfig(rootDir: string): string | null;
|
|
3
|
+
export interface RawCustomTheme {
|
|
4
|
+
colors?: Palette;
|
|
5
|
+
spacing?: Record<string, number>;
|
|
6
|
+
}
|
|
7
|
+
export declare function loadCustomTheme(configPath: string): RawCustomTheme | null;
|
|
8
|
+
export declare function mergePalette(base: Palette, extend?: Palette): Palette;
|
|
9
|
+
export declare function mergeSpacing(base: Record<string, number>, extend?: Record<string, number>): Record<string, number>;
|
|
10
|
+
export interface ResolvedTheme {
|
|
11
|
+
palette: Palette;
|
|
12
|
+
spacing: Record<string, number>;
|
|
13
|
+
configError?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveTheme(opts: {
|
|
16
|
+
rootDir: string | null;
|
|
17
|
+
configPath?: string | null;
|
|
18
|
+
}): ResolvedTheme;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { hexToRgb } from "../contrast/luminance.js";
|
|
5
|
+
import { defaultPalette } from "./defaultPalette.js";
|
|
6
|
+
import { spacingScale } from "./spacingScale.js";
|
|
7
|
+
const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs"];
|
|
8
|
+
// v1 only looks in the given directory itself -- no ancestor-directory search.
|
|
9
|
+
// --config (CLI) / settings["tailwind-a11y"].configPath (ESLint) exist as
|
|
10
|
+
// explicit escape hatches for projects where this isn't enough. `rootDir` must
|
|
11
|
+
// be an absolute path.
|
|
12
|
+
export function findTailwindConfig(rootDir) {
|
|
13
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
14
|
+
const candidate = join(rootDir, filename);
|
|
15
|
+
if (existsSync(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const SPACING_RE = /^-?[\d.]+(rem|px)$/;
|
|
21
|
+
function parseSpacingValue(value) {
|
|
22
|
+
if (typeof value !== "string")
|
|
23
|
+
return null;
|
|
24
|
+
const match = SPACING_RE.exec(value);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null; // em/%/vw/bare number/function -- skip, don't guess
|
|
27
|
+
const num = parseFloat(value);
|
|
28
|
+
return match[1] === "rem" ? num * 16 : num; // matches spacingScale.ts's 16px-root assumption
|
|
29
|
+
}
|
|
30
|
+
// Only plain hex-shade objects are accepted (e.g. `brand: { 500: '#3490dc' }`).
|
|
31
|
+
// A flat string color (`brand: '#3490dc'`) or a `DEFAULT` key is skipped
|
|
32
|
+
// entirely -- there's no class syntax ("bg-brand-DEFAULT" isn't real Tailwind)
|
|
33
|
+
// that would ever resolve to it, so partially supporting it would be dead code.
|
|
34
|
+
function parseColorScale(value) {
|
|
35
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
36
|
+
return null;
|
|
37
|
+
const shades = {};
|
|
38
|
+
for (const [shade, shadeValue] of Object.entries(value)) {
|
|
39
|
+
if (shade === "DEFAULT")
|
|
40
|
+
continue; // no "bg-brand-DEFAULT" class syntax exists to resolve it
|
|
41
|
+
if (typeof shadeValue !== "string")
|
|
42
|
+
continue;
|
|
43
|
+
if (hexToRgb(shadeValue) === null)
|
|
44
|
+
continue; // not a hex value -- skip, don't guess
|
|
45
|
+
shades[shade] = shadeValue;
|
|
46
|
+
}
|
|
47
|
+
return Object.keys(shades).length > 0 ? shades : null;
|
|
48
|
+
}
|
|
49
|
+
// Loads a tailwind.config.js/.cjs and extracts only `theme.extend.colors` /
|
|
50
|
+
// `theme.extend.spacing` -- v1 does not read a full `theme.colors`/`theme.spacing`
|
|
51
|
+
// replacement, Tailwind v4's CSS-based `@theme` config, or .mjs/.ts configs (no
|
|
52
|
+
// config-transpiling dependency exists in this package). `configPath` must be
|
|
53
|
+
// an absolute path (require() resolves relative paths against this module's
|
|
54
|
+
// own location, not the caller's cwd).
|
|
55
|
+
//
|
|
56
|
+
// Node's require() cache is busted before loading -- recursively, for the
|
|
57
|
+
// config file *and* everything it required (e.g. a config that factors
|
|
58
|
+
// tokens into a separate `require('./colors.js')`) -- without this, a
|
|
59
|
+
// long-lived process (the VS Code extension host, an editor-integrated
|
|
60
|
+
// ESLint server) would keep serving a stale value forever after the user
|
|
61
|
+
// edits any file the config depends on, not just the config file itself.
|
|
62
|
+
function bustRequireCache(require, mod, seen) {
|
|
63
|
+
if (seen.has(mod.id))
|
|
64
|
+
return;
|
|
65
|
+
seen.add(mod.id);
|
|
66
|
+
for (const child of mod.children)
|
|
67
|
+
bustRequireCache(require, child, seen);
|
|
68
|
+
delete require.cache[mod.id];
|
|
69
|
+
}
|
|
70
|
+
// Returns null only when the file itself couldn't be loaded (missing,
|
|
71
|
+
// syntax error, ERR_REQUIRE_ESM for a "type": "module" project, or a config
|
|
72
|
+
// that throws) -- a config that loads fine but has no theme.extend colors or
|
|
73
|
+
// spacing returns {}, which callers must not treat as an error.
|
|
74
|
+
export function loadCustomTheme(configPath) {
|
|
75
|
+
try {
|
|
76
|
+
const require = createRequire(import.meta.url);
|
|
77
|
+
const resolved = require.resolve(configPath);
|
|
78
|
+
const cached = require.cache[resolved];
|
|
79
|
+
if (cached)
|
|
80
|
+
bustRequireCache(require, cached, new Set());
|
|
81
|
+
const config = require(resolved);
|
|
82
|
+
const extend = config?.theme?.extend ?? {};
|
|
83
|
+
const result = {};
|
|
84
|
+
if (extend.colors && typeof extend.colors === "object") {
|
|
85
|
+
const colors = {};
|
|
86
|
+
for (const [scale, value] of Object.entries(extend.colors)) {
|
|
87
|
+
const shades = parseColorScale(value);
|
|
88
|
+
if (shades)
|
|
89
|
+
colors[scale] = shades;
|
|
90
|
+
}
|
|
91
|
+
if (Object.keys(colors).length > 0)
|
|
92
|
+
result.colors = colors;
|
|
93
|
+
}
|
|
94
|
+
if (extend.spacing && typeof extend.spacing === "object") {
|
|
95
|
+
const spacing = {};
|
|
96
|
+
for (const [token, value] of Object.entries(extend.spacing)) {
|
|
97
|
+
const px = parseSpacingValue(value);
|
|
98
|
+
if (px !== null)
|
|
99
|
+
spacing[token] = px;
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(spacing).length > 0)
|
|
102
|
+
result.spacing = spacing;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// New scale names are added wholesale; extending an existing scale merges
|
|
111
|
+
// shade keys in without dropping the scale's other (default) shades.
|
|
112
|
+
export function mergePalette(base, extend) {
|
|
113
|
+
if (!extend)
|
|
114
|
+
return base;
|
|
115
|
+
const merged = { ...base };
|
|
116
|
+
for (const [scale, shades] of Object.entries(extend)) {
|
|
117
|
+
merged[scale] = { ...merged[scale], ...shades };
|
|
118
|
+
}
|
|
119
|
+
return merged;
|
|
120
|
+
}
|
|
121
|
+
export function mergeSpacing(base, extend) {
|
|
122
|
+
return extend ? { ...base, ...extend } : base;
|
|
123
|
+
}
|
|
124
|
+
// configError is only ever set when a path was *explicitly* provided (CLI
|
|
125
|
+
// --config flag or ESLint settings) and failed to load -- auto-detected
|
|
126
|
+
// absence stays silent, matching the "avoid confusing noise in an unrelated
|
|
127
|
+
// linter run" precedent already in the ESLint plugin's index.ts. `rootDir`
|
|
128
|
+
// and `configPath` (if given) must be absolute paths.
|
|
129
|
+
export function resolveTheme(opts) {
|
|
130
|
+
const explicitPath = opts.configPath ?? null;
|
|
131
|
+
const configPath = explicitPath ?? (opts.rootDir ? findTailwindConfig(opts.rootDir) : null);
|
|
132
|
+
if (!configPath) {
|
|
133
|
+
return { palette: defaultPalette, spacing: spacingScale };
|
|
134
|
+
}
|
|
135
|
+
const custom = loadCustomTheme(configPath);
|
|
136
|
+
if (custom === null) {
|
|
137
|
+
return explicitPath
|
|
138
|
+
? {
|
|
139
|
+
palette: defaultPalette,
|
|
140
|
+
spacing: spacingScale,
|
|
141
|
+
configError: `could not load Tailwind config at ${explicitPath}`,
|
|
142
|
+
}
|
|
143
|
+
: { palette: defaultPalette, spacing: spacingScale };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
palette: mergePalette(defaultPalette, custom.colors),
|
|
147
|
+
spacing: mergeSpacing(spacingScale, custom.spacing),
|
|
148
|
+
};
|
|
149
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal — in Tailwind CSS class combinations before they ship.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|