tailwind-a11y 0.9.0 → 0.12.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 +3 -1
- package/dist/cli.js +14 -2
- package/dist/cliArgs.js +8 -2
- package/dist/contrast/luminance.d.ts +1 -0
- package/dist/contrast/luminance.js +9 -0
- package/dist/contrast/oklch.d.ts +2 -0
- package/dist/contrast/oklch.js +67 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/parser/extractFocusIndicators.d.ts +2 -0
- package/dist/parser/extractFocusIndicators.js +20 -0
- package/dist/parser/extractReducedMotion.d.ts +7 -0
- package/dist/parser/extractReducedMotion.js +50 -0
- package/dist/rules/checkContrast.js +4 -1
- package/dist/rules/checkFocusIndicator.d.ts +15 -0
- package/dist/rules/checkFocusIndicator.js +109 -0
- package/dist/rules/checkReducedMotion.d.ts +11 -0
- package/dist/rules/checkReducedMotion.js +109 -0
- package/dist/theme/themeValueParsers.js +19 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ npm install --save-dev tailwind-a11y
|
|
|
25
25
|
npx tailwind-a11y # scans **/*.{jsx,tsx}
|
|
26
26
|
npx tailwind-a11y "src/**/*.tsx" # custom glob
|
|
27
27
|
npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
|
|
28
|
-
npx tailwind-a11y --strict # touch targets
|
|
28
|
+
npx tailwind-a11y --strict # AAA tier: touch targets 2.5.5, focus indicators also 2.4.13, and enables the reduced-motion check (2.3.3)
|
|
29
29
|
npx tailwind-a11y --config ./tw.config.cjs # use a specific config instead of auto-detecting
|
|
30
30
|
npx tailwind-a11y --version # print the installed version
|
|
31
31
|
npx tailwind-a11y --help # usage and all options
|
|
@@ -56,6 +56,8 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
56
56
|
| 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 |
|
|
57
57
|
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px — or 44×44px with `--strict` (2.5.5, AAA) |
|
|
58
58
|
| Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
|
|
59
|
+
| Focus indicator contrast | 1.4.11 (AA) | A present `outline-*`/`ring-*` focus indicator below 3:1 contrast — or also below the 2px minimum thickness with `--strict` (2.4.13, AAA) |
|
|
60
|
+
| Reduced motion | 2.3.3 (AAA, `--strict` only) | A `hover:`/`focus:`/`focus-visible:`/`active:`-scoped `scale-*`/`rotate-*`/`translate-*`/`skew-*` change with an unscoped `transition`/`transition-all`/`transition-transform` and no `motion-reduce:`/`motion-safe:` handling |
|
|
59
61
|
|
|
60
62
|
## Scope
|
|
61
63
|
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,9 @@ import { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js
|
|
|
8
8
|
import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
|
|
9
9
|
import { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
10
10
|
import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
11
|
-
import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
11
|
+
import { checkFocusContrast, checkFocusIndicators, } from "./rules/checkFocusIndicator.js";
|
|
12
|
+
import { extractReducedMotionChecks } from "./parser/extractReducedMotion.js";
|
|
13
|
+
import { checkReducedMotion } from "./rules/checkReducedMotion.js";
|
|
12
14
|
import { parseArgs, getHelpText } from "./cliArgs.js";
|
|
13
15
|
import { resolveTheme } from "./theme/loadCustomTheme.js";
|
|
14
16
|
// ../package.json resolves correctly from both src/ (dev) and dist/ (published).
|
|
@@ -26,6 +28,15 @@ function formatViolation(v) {
|
|
|
26
28
|
}
|
|
27
29
|
case "focus-indicator":
|
|
28
30
|
return `${v.line}: <${v.tagName}> removes the focus outline (${v.removalClass}) with no visible replacement (focus:ring-*/border-*/shadow-*/bg-*/outline-*)`;
|
|
31
|
+
case "focus-contrast": {
|
|
32
|
+
const sc = v.level === "AAA" ? "2.4.13" : "1.4.11";
|
|
33
|
+
const base = `${v.line}: <${v.tagName}> focus indicator ${v.indicatorClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (WCAG ${sc})`;
|
|
34
|
+
return v.thicknessPx !== undefined
|
|
35
|
+
? `${base}; also only ${v.thicknessPx}px thick, needs >= ${v.requiredThicknessPx}px`
|
|
36
|
+
: base;
|
|
37
|
+
}
|
|
38
|
+
case "reduced-motion":
|
|
39
|
+
return `${v.line}: <${v.tagName}> animates ${v.motionClass} via ${v.transitionClass} with no motion-reduce:transition-none/transform-none guard — WCAG 2.3.3 requires motion animation triggered by interaction to be disableable`;
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
42
|
function groupByFile(items) {
|
|
@@ -79,7 +90,8 @@ async function main() {
|
|
|
79
90
|
try {
|
|
80
91
|
const code = readFileSync(absPath, "utf8");
|
|
81
92
|
const contrastChecks = extractChecks(code, file);
|
|
82
|
-
|
|
93
|
+
const focusChecks = extractFocusIndicatorChecks(code, file);
|
|
94
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette), ...checkReducedMotion(extractReducedMotionChecks(code, file), strict));
|
|
83
95
|
if (verbose) {
|
|
84
96
|
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
85
97
|
}
|
package/dist/cliArgs.js
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
const HELP_TEXT = `Usage: tailwind-a11y [options] [<glob>...]
|
|
4
4
|
|
|
5
5
|
Static analysis for Tailwind CSS accessibility violations -- color contrast,
|
|
6
|
-
touch target size,
|
|
6
|
+
touch target size, focus indicator removal/contrast, and reduced-motion
|
|
7
|
+
support for interaction-triggered animation.
|
|
7
8
|
|
|
8
9
|
Options:
|
|
9
10
|
-v, --verbose Also report what couldn't be checked, and why
|
|
10
11
|
-V, --version Print the version number
|
|
11
12
|
-h, --help Print this help message
|
|
12
13
|
--strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
|
|
13
|
-
instead of the default 2.5.8 (AA, 24x24px)
|
|
14
|
+
instead of the default 2.5.8 (AA, 24x24px); focus
|
|
15
|
+
indicators are also held to 2.4.13 (AAA, minimum
|
|
16
|
+
thickness) alongside the default 1.4.11 (AA, contrast);
|
|
17
|
+
also enables the reduced-motion check (WCAG 2.3.3,
|
|
18
|
+
AAA-only, off by default -- has no AA tier to fall
|
|
19
|
+
back to)
|
|
14
20
|
--config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
|
|
15
21
|
@theme file like app/globals.css (v4) to read
|
|
16
22
|
custom theme colors/spacing from (default:
|
|
@@ -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 rgbToHex(rgb: RGB): string;
|
|
7
8
|
export declare function applyAlpha(fg: RGB, alpha: number, bg: RGB): RGB;
|
|
8
9
|
export declare function relativeLuminance(rgb: RGB): number;
|
|
9
10
|
export declare function contrastRatio(rgb1: RGB, rgb2: RGB): number;
|
|
@@ -16,6 +16,15 @@ export function hexToRgb(hex) {
|
|
|
16
16
|
b: parseInt(digits.slice(4, 6), 16),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
// Inverse of hexToRgb -- used once, by themeValueParsers.ts's
|
|
20
|
+
// parseColorScale(), to turn an accepted oklch() value back into the plain
|
|
21
|
+
// hex string every downstream consumer already expects a palette shade to
|
|
22
|
+
// be. Channels are clamped and rounded rather than assumed already valid,
|
|
23
|
+
// since a caller could in principle pass an out-of-range value.
|
|
24
|
+
export function rgbToHex(rgb) {
|
|
25
|
+
const channel = (c) => Math.round(Math.min(255, Math.max(0, c))).toString(16).padStart(2, "0");
|
|
26
|
+
return `#${channel(rgb.r)}${channel(rgb.g)}${channel(rgb.b)}`;
|
|
27
|
+
}
|
|
19
28
|
// Standard "src-over" compositing of a foreground at `alpha` opacity over an
|
|
20
29
|
// opaque background, in gamma-encoded sRGB space (0-255 channels) -- matches
|
|
21
30
|
// how browsers actually composite CSS opacity, no linear-light conversion
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// L and C each accept a plain number or a percentage; H accepts a plain
|
|
2
|
+
// degree number or an explicit `deg` suffix (`180` and `180deg` render
|
|
3
|
+
// identically -- confirmed against a real browser, see below). An alpha
|
|
4
|
+
// component (`/ A`) or the `none` keyword for any channel is deliberately
|
|
5
|
+
// unsupported and falls through to the "not oklch" case below: the palette
|
|
6
|
+
// only ever stores fully opaque colors today (HEX_RE in luminance.ts
|
|
7
|
+
// doesn't accept 4/8-digit hex with alpha either), so this mirrors an
|
|
8
|
+
// existing limit rather than introducing a new one.
|
|
9
|
+
// A real number shape only -- `\d+(?:\.\d+)?|\.\d+` matches "5", "5.5", and
|
|
10
|
+
// ".5" but not "5." or "0..5". The looser `[\d.]+` this replaced accepted
|
|
11
|
+
// "0..5" too, which Number() coerces to NaN -- silently producing an
|
|
12
|
+
// {r:NaN,g:NaN,b:NaN} object where oklchToRgb's contract says this should
|
|
13
|
+
// have been rejected outright (caught in review; the isFinite guard below
|
|
14
|
+
// is a second, independent layer against the same failure mode).
|
|
15
|
+
const NUM = String.raw `\d+(?:\.\d+)?|\.\d+`;
|
|
16
|
+
const OKLCH_RE = new RegExp(`^oklch\\(\\s*(${NUM})(%)?\\s+(${NUM})(%)?\\s+(${NUM})(deg)?\\s*\\)$`);
|
|
17
|
+
// Standard OKLab <-> linear sRGB conversion matrices (Björn Ottosson's
|
|
18
|
+
// published reference, the same ones browsers implement per CSS Color 4).
|
|
19
|
+
// Verified this session against a real headless Chrome: rendered each of
|
|
20
|
+
// white/black/a midtone gray/saturated red/teal/purple/an intentionally
|
|
21
|
+
// out-of-gamut saturated red-orange/percentage-L/percentage-C/a deg-suffixed
|
|
22
|
+
// hue onto a <canvas> and read back the actual pixel RGB via getImageData()
|
|
23
|
+
// -- getComputedStyle().color isn't usable for this, since modern Chrome
|
|
24
|
+
// serializes a computed oklch() value back out as oklch(), not rgb(). Every
|
|
25
|
+
// case matched the real browser-rendered pixel exactly (0 channel
|
|
26
|
+
// difference), including the out-of-gamut case, confirming both the
|
|
27
|
+
// matrices and the clamp-before-gamma-encode approach below are correct as
|
|
28
|
+
// implemented, not just approximately close.
|
|
29
|
+
function gammaEncode(linear) {
|
|
30
|
+
const clamped = Math.min(1, Math.max(0, linear));
|
|
31
|
+
return clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * clamped ** (1 / 2.4) - 0.055;
|
|
32
|
+
}
|
|
33
|
+
export function oklchToRgb(value) {
|
|
34
|
+
const match = OKLCH_RE.exec(value.trim());
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
const [, lRaw, lPct, cRaw, cPct, hRaw] = match;
|
|
38
|
+
const L = lPct ? Number(lRaw) / 100 : Number(lRaw);
|
|
39
|
+
const C = cPct ? (Number(cRaw) / 100) * 0.4 : Number(cRaw); // 100% chroma == 0.4, the CSS Color 4 reference range
|
|
40
|
+
const H = Number(hRaw);
|
|
41
|
+
// Belt-and-suspenders: independent of whether the regex above is airtight,
|
|
42
|
+
// this makes the RGB | null contract impossible to violate -- a NaN here
|
|
43
|
+
// would otherwise propagate silently into a "successfully resolved" but
|
|
44
|
+
// garbage color (rgbToHex would render it as the literal string
|
|
45
|
+
// "#NaNNaNNaN", which downstream code happens to reject today, but only
|
|
46
|
+
// by accident of a stricter hex regex elsewhere, not because this
|
|
47
|
+
// function actually enforced its own contract).
|
|
48
|
+
if (!Number.isFinite(L) || !Number.isFinite(C) || !Number.isFinite(H))
|
|
49
|
+
return null;
|
|
50
|
+
const hRad = (H * Math.PI) / 180;
|
|
51
|
+
const a = C * Math.cos(hRad);
|
|
52
|
+
const b = C * Math.sin(hRad);
|
|
53
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
54
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
55
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
56
|
+
const l = l_ ** 3;
|
|
57
|
+
const m = m_ ** 3;
|
|
58
|
+
const s = s_ ** 3;
|
|
59
|
+
const rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
60
|
+
const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
61
|
+
const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
62
|
+
return {
|
|
63
|
+
r: Math.round(gammaEncode(rLin) * 255),
|
|
64
|
+
g: Math.round(gammaEncode(gLin) * 255),
|
|
65
|
+
b: Math.round(gammaEncode(bLin) * 255),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ export { checkContrast, checkContrastValueSkips, suggestContrastFix, type Contra
|
|
|
3
3
|
export { extractTouchTargetChecks, extractTouchTargetSkips, type TouchTargetCheck, type TouchTargetSkip } from "./parser/extractTouchTargets.js";
|
|
4
4
|
export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouchTarget.js";
|
|
5
5
|
export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
|
|
6
|
-
export { checkFocusIndicators, type FocusIndicatorViolation } from "./rules/checkFocusIndicator.js";
|
|
6
|
+
export { checkFocusIndicators, checkFocusContrast, type FocusIndicatorViolation, type FocusContrastViolation, } from "./rules/checkFocusIndicator.js";
|
|
7
|
+
export { extractReducedMotionChecks, type ReducedMotionCheck } from "./parser/extractReducedMotion.js";
|
|
8
|
+
export { checkReducedMotion, type ReducedMotionViolation } from "./rules/checkReducedMotion.js";
|
|
7
9
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
|
|
8
10
|
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
|
|
9
11
|
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,9 @@ export { checkContrast, checkContrastValueSkips, suggestContrastFix } from "./ru
|
|
|
3
3
|
export { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
|
|
4
4
|
export { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
5
5
|
export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
6
|
-
export { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
6
|
+
export { checkFocusIndicators, checkFocusContrast, } from "./rules/checkFocusIndicator.js";
|
|
7
|
+
export { extractReducedMotionChecks } from "./parser/extractReducedMotion.js";
|
|
8
|
+
export { checkReducedMotion } from "./rules/checkReducedMotion.js";
|
|
7
9
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
|
|
8
10
|
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
|
|
9
11
|
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
|
@@ -3,5 +3,7 @@ export interface FocusIndicatorCheck {
|
|
|
3
3
|
line: number;
|
|
4
4
|
tagName: string;
|
|
5
5
|
focusClasses: string[];
|
|
6
|
+
bgClass?: string | null;
|
|
7
|
+
bgSource?: "self" | "parent" | null;
|
|
6
8
|
}
|
|
7
9
|
export declare function extractFocusIndicatorChecks(code: string, filePath: string): FocusIndicatorCheck[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as t from "@babel/types";
|
|
2
2
|
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
3
|
import { isInteractiveElement } from "./isInteractiveElement.js";
|
|
4
|
+
import { lastColorToken } from "./extractClasses.js";
|
|
4
5
|
function focusScopedClasses(className) {
|
|
5
6
|
return className
|
|
6
7
|
.split(/\s+/)
|
|
@@ -27,11 +28,30 @@ export function extractFocusIndicatorChecks(code, filePath) {
|
|
|
27
28
|
const focusClasses = focusScopedClasses(className);
|
|
28
29
|
if (focusClasses.length === 0)
|
|
29
30
|
return; // nothing under focus:/focus-visible: — not a candidate
|
|
31
|
+
const ownBg = lastColorToken(className, "bg");
|
|
32
|
+
let bgClass = ownBg;
|
|
33
|
+
let bgSource = ownBg ? "self" : null;
|
|
34
|
+
if (!bgClass) {
|
|
35
|
+
// Only the immediate JSX parent, same limit as extractClasses.ts's
|
|
36
|
+
// own contrast resolution — no deeper ancestor walk, no
|
|
37
|
+
// cross-component resolution.
|
|
38
|
+
const parentNode = path.parentPath?.node;
|
|
39
|
+
if (parentNode && t.isJSXElement(parentNode)) {
|
|
40
|
+
const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
|
|
41
|
+
const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
|
|
42
|
+
if (parentBg) {
|
|
43
|
+
bgClass = parentBg;
|
|
44
|
+
bgSource = "parent";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
checks.push({
|
|
31
49
|
file: filePath,
|
|
32
50
|
line: opening.loc?.start.line ?? 0,
|
|
33
51
|
tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "onClick-element",
|
|
34
52
|
focusClasses,
|
|
53
|
+
bgClass,
|
|
54
|
+
bgSource,
|
|
35
55
|
});
|
|
36
56
|
},
|
|
37
57
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
|
+
const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-transform"]);
|
|
4
|
+
const INTERACTION_VARIANTS = new Set(["hover", "focus", "focus-visible", "focus-within", "active"]);
|
|
5
|
+
function baseUtility(raw) {
|
|
6
|
+
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
7
|
+
}
|
|
8
|
+
// Every variant applied to a class, in source order -- NOT just the one
|
|
9
|
+
// immediately before the base utility. Caught in independent review:
|
|
10
|
+
// Tailwind variants stack (`motion-safe:hover:scale-110` and
|
|
11
|
+
// `hover:motion-safe:scale-110` compile to the identical nested media query,
|
|
12
|
+
// confirmed against a real v4 build), so checking only the innermost
|
|
13
|
+
// segment made this extractor's own candidacy gate order-dependent -- it
|
|
14
|
+
// would only recognize the interaction variant when it happened to be
|
|
15
|
+
// written last, silently missing the equally valid `hover:motion-safe:...`
|
|
16
|
+
// ordering. checkReducedMotion.ts uses the same helper for the same reason.
|
|
17
|
+
function variantSegments(raw) {
|
|
18
|
+
return raw.split(":").slice(0, -1);
|
|
19
|
+
}
|
|
20
|
+
export function extractReducedMotionChecks(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
|
+
const className = getStaticClassName(opening.attributes);
|
|
29
|
+
if (!className)
|
|
30
|
+
return;
|
|
31
|
+
const classes = className.split(/\s+/).filter(Boolean);
|
|
32
|
+
const hasTransitionBase = classes.some((raw) => TRANSITION_BASES.has(baseUtility(raw)));
|
|
33
|
+
const hasInteractionClass = classes.some((raw) => variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v)));
|
|
34
|
+
// Not a candidate at all unless there's some transition utility
|
|
35
|
+
// (scoped or not) *and* some interaction-scoped class -- narrows the
|
|
36
|
+
// set of elements checkReducedMotion.ts has to reason about, without
|
|
37
|
+
// pre-deciding any of the nuance (unscoped vs motion-safe:, identity
|
|
38
|
+
// values, motion-reduce: guards) that belongs in the rule.
|
|
39
|
+
if (!hasTransitionBase || !hasInteractionClass)
|
|
40
|
+
return;
|
|
41
|
+
checks.push({
|
|
42
|
+
file: filePath,
|
|
43
|
+
line: opening.loc?.start.line ?? 0,
|
|
44
|
+
tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "unknown-element",
|
|
45
|
+
classes,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return checks;
|
|
50
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { applyAlpha, contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
|
|
2
2
|
import { defaultPalette, semanticColors } from "../theme/defaultPalette.js";
|
|
3
3
|
export function resolveColorValue(utilityClass, palette = defaultPalette) {
|
|
4
|
-
|
|
4
|
+
// outline/ring are here for checkFocusIndicator.ts's non-text-contrast
|
|
5
|
+
// check (WCAG 1.4.11/2.4.13) -- same palette/arbitrary-hex/semantic-color
|
|
6
|
+
// resolution as text/bg, just a different utility prefix.
|
|
7
|
+
const match = /^(?:text|bg|outline|ring)-(.+)$/.exec(utilityClass);
|
|
5
8
|
if (!match)
|
|
6
9
|
return null;
|
|
7
10
|
const token = match[1];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FocusIndicatorCheck } from "../parser/extractFocusIndicators.js";
|
|
2
|
+
import type { Palette } from "../theme/defaultPalette.js";
|
|
2
3
|
export interface FocusIndicatorViolation {
|
|
3
4
|
type: "focus-indicator";
|
|
4
5
|
file: string;
|
|
@@ -7,3 +8,17 @@ export interface FocusIndicatorViolation {
|
|
|
7
8
|
removalClass: string;
|
|
8
9
|
}
|
|
9
10
|
export declare function checkFocusIndicators(checks: FocusIndicatorCheck[]): FocusIndicatorViolation[];
|
|
11
|
+
export interface FocusContrastViolation {
|
|
12
|
+
type: "focus-contrast";
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
tagName: string;
|
|
16
|
+
indicatorClass: string;
|
|
17
|
+
bgClass: string;
|
|
18
|
+
ratio: number;
|
|
19
|
+
required: number;
|
|
20
|
+
level: "AA" | "AAA";
|
|
21
|
+
thicknessPx?: number;
|
|
22
|
+
requiredThicknessPx?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function checkFocusContrast(checks: FocusIndicatorCheck[], strict?: boolean, palette?: Palette): FocusContrastViolation[];
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { COLOR_TOKEN } from "../parser/extractClasses.js";
|
|
2
|
+
import { resolveColorValue } from "./checkContrast.js";
|
|
3
|
+
import { contrastRatio, hexToRgb } from "../contrast/luminance.js";
|
|
4
|
+
import { defaultPalette } from "../theme/defaultPalette.js";
|
|
2
5
|
const REMOVAL_BASE = "outline-none";
|
|
3
6
|
// Utilities that match the "replacement" shape but are semantically no-ops —
|
|
4
7
|
// the same failure mode as bg-opacity-50 masking a real color match: a
|
|
@@ -97,3 +100,109 @@ export function checkFocusIndicators(checks) {
|
|
|
97
100
|
}
|
|
98
101
|
return violations;
|
|
99
102
|
}
|
|
103
|
+
// Flat non-text threshold -- not luminance.ts's requiredRatio()/meetsWCAG(),
|
|
104
|
+
// which model the text-specific large-text/small-text AA/AAA table that
|
|
105
|
+
// doesn't apply to UI-component contrast.
|
|
106
|
+
const NON_TEXT_MIN_RATIO = 3;
|
|
107
|
+
// WCAG 2.4.13's "2 CSS pixel thick perimeter" -- verified against a real
|
|
108
|
+
// tailwindcss@4.3.3 compile (see CLAUDE.md) rather than assumed.
|
|
109
|
+
const FOCUS_INDICATOR_MIN_THICKNESS_PX = 2;
|
|
110
|
+
// outline-{0,1,2,4,8} / ring-{0,1,2,4,8} -- Tailwind's fixed width scale for
|
|
111
|
+
// both utilities, verified against a real build. Bare `ring`/`outline` (no
|
|
112
|
+
// digit) is deliberately NOT in this map: verified via that same build that
|
|
113
|
+
// it resolves to 1px in Tailwind v4, but v3's bare `ring` default is
|
|
114
|
+
// documented elsewhere as 3px -- a real cross-version difference this tool
|
|
115
|
+
// can't currently distinguish, so bare ring/outline contributes color (if
|
|
116
|
+
// paired with an explicit color utility) but never a thickness value.
|
|
117
|
+
const WIDTH_SCALE = { "0": 0, "1": 1, "2": 2, "4": 4, "8": 8 };
|
|
118
|
+
// Near-duplicate of extractClasses.ts's lastColorToken, not a call to it:
|
|
119
|
+
// that function takes a single prefix ("text" | "bg") and this needs
|
|
120
|
+
// last-token-wins across *two* prefixes (outline-*, ring-*) in one pass, so
|
|
121
|
+
// whichever was actually written last in the class list wins regardless of
|
|
122
|
+
// which utility it is. Reuses COLOR_TOKEN (the one shared "is this
|
|
123
|
+
// color-shaped" test) and only excludes the "opacity" scale name locally --
|
|
124
|
+
// lastColorToken's full NON_COLOR_SCALE_NAMES set also excludes
|
|
125
|
+
// "linear"/"conic", but those are bg-gradient-angle utilities with no
|
|
126
|
+
// outline-*/ring-* equivalent, so they can never appear here.
|
|
127
|
+
function lastIndicatorColorToken(focusClasses) {
|
|
128
|
+
let found = null;
|
|
129
|
+
for (const raw of focusClasses) {
|
|
130
|
+
const base = raw.slice(raw.lastIndexOf(":") + 1);
|
|
131
|
+
const match = /^(?:outline|ring)-(.+)$/.exec(base);
|
|
132
|
+
if (!match)
|
|
133
|
+
continue;
|
|
134
|
+
const rest = match[1];
|
|
135
|
+
if (!COLOR_TOKEN.test(rest))
|
|
136
|
+
continue;
|
|
137
|
+
const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
|
|
138
|
+
if (scaleName === "opacity")
|
|
139
|
+
continue;
|
|
140
|
+
found = base;
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
// Same last-token-wins-across-both-prefixes shape as above, but for width:
|
|
145
|
+
// only an enumerated outline-{N}/ring-{N} or an arbitrary [Npx] sets a
|
|
146
|
+
// thickness. A color token (ring-blue-400), ring-offset-*, ring-inset, etc.
|
|
147
|
+
// don't match either shape and are silently ignored here -- they're a
|
|
148
|
+
// different utility's job (color, offset, inset), not this one's.
|
|
149
|
+
function lastIndicatorThicknessPx(focusClasses) {
|
|
150
|
+
let found = null;
|
|
151
|
+
for (const raw of focusClasses) {
|
|
152
|
+
const base = raw.slice(raw.lastIndexOf(":") + 1);
|
|
153
|
+
const match = /^(?:outline|ring)-(.+)$/.exec(base);
|
|
154
|
+
if (!match)
|
|
155
|
+
continue;
|
|
156
|
+
const token = match[1];
|
|
157
|
+
if (token in WIDTH_SCALE) {
|
|
158
|
+
found = WIDTH_SCALE[token];
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const arbitrary = /^\[(\d+(?:\.\d+)?)px\]$/.exec(token);
|
|
162
|
+
if (arbitrary)
|
|
163
|
+
found = Number(arbitrary[1]);
|
|
164
|
+
}
|
|
165
|
+
return found;
|
|
166
|
+
}
|
|
167
|
+
export function checkFocusContrast(checks, strict = false, palette = defaultPalette) {
|
|
168
|
+
const violations = [];
|
|
169
|
+
for (const check of checks) {
|
|
170
|
+
if (!check.bgClass)
|
|
171
|
+
continue; // no resolvable background — skip, not a guess
|
|
172
|
+
const indicatorBase = lastIndicatorColorToken(check.focusClasses);
|
|
173
|
+
if (!indicatorBase)
|
|
174
|
+
continue; // no explicit outline-*/ring-* color — out of scope, see CLAUDE.md
|
|
175
|
+
const indicatorHex = resolveColorValue(indicatorBase, palette);
|
|
176
|
+
const bgHex = resolveColorValue(check.bgClass, palette);
|
|
177
|
+
if (!indicatorHex || !bgHex)
|
|
178
|
+
continue; // custom theme color / unsupported arbitrary value — skip
|
|
179
|
+
const indicatorRgb = hexToRgb(indicatorHex);
|
|
180
|
+
const bgRgb = hexToRgb(bgHex);
|
|
181
|
+
if (!indicatorRgb || !bgRgb)
|
|
182
|
+
continue;
|
|
183
|
+
const ratio = contrastRatio(indicatorRgb, bgRgb);
|
|
184
|
+
const contrastFails = ratio < NON_TEXT_MIN_RATIO;
|
|
185
|
+
let thicknessPx = null;
|
|
186
|
+
if (strict)
|
|
187
|
+
thicknessPx = lastIndicatorThicknessPx(check.focusClasses);
|
|
188
|
+
const thicknessFails = strict && thicknessPx !== null && thicknessPx < FOCUS_INDICATOR_MIN_THICKNESS_PX;
|
|
189
|
+
if (!contrastFails && !thicknessFails)
|
|
190
|
+
continue;
|
|
191
|
+
const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === indicatorBase);
|
|
192
|
+
violations.push({
|
|
193
|
+
type: "focus-contrast",
|
|
194
|
+
file: check.file,
|
|
195
|
+
line: check.line,
|
|
196
|
+
tagName: check.tagName,
|
|
197
|
+
indicatorClass: rawIndicatorClass,
|
|
198
|
+
bgClass: check.bgClass,
|
|
199
|
+
ratio,
|
|
200
|
+
required: NON_TEXT_MIN_RATIO,
|
|
201
|
+
level: thicknessFails ? "AAA" : "AA",
|
|
202
|
+
...(thicknessFails && thicknessPx !== null
|
|
203
|
+
? { thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
|
|
204
|
+
: {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return violations;
|
|
208
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReducedMotionCheck } from "../parser/extractReducedMotion.js";
|
|
2
|
+
export interface ReducedMotionViolation {
|
|
3
|
+
type: "reduced-motion";
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
tagName: string;
|
|
7
|
+
transitionClass: string;
|
|
8
|
+
motionClass: string;
|
|
9
|
+
level: "AAA";
|
|
10
|
+
}
|
|
11
|
+
export declare function checkReducedMotion(checks: ReducedMotionCheck[], strict?: boolean): ReducedMotionViolation[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Verified against a real Tailwind v4 build: only these three include
|
|
2
|
+
// transform/translate/scale/rotate in their transition-property list.
|
|
3
|
+
// transition-colors/-opacity/-shadow don't -- so a scale/rotate/translate
|
|
4
|
+
// change under hover on an element with only one of those never actually
|
|
5
|
+
// animates (the browser has nothing telling it to transition that
|
|
6
|
+
// property), and correctly isn't flagged.
|
|
7
|
+
const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-transform"]);
|
|
8
|
+
const INTERACTION_VARIANTS = new Set(["hover", "focus", "focus-visible", "focus-within", "active"]);
|
|
9
|
+
function baseUtility(raw) {
|
|
10
|
+
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
11
|
+
}
|
|
12
|
+
// Every variant applied to a class, in source order -- see the identical
|
|
13
|
+
// helper (and full explanation) in extractReducedMotion.ts. Duplicated
|
|
14
|
+
// rather than imported, matching this codebase's own established
|
|
15
|
+
// precedent of small per-file primitives (e.g. checkFocusIndicator.ts's own
|
|
16
|
+
// baseUtility is separate from extractFocusIndicators.ts's).
|
|
17
|
+
function variantSegments(raw) {
|
|
18
|
+
return raw.split(":").slice(0, -1);
|
|
19
|
+
}
|
|
20
|
+
// Positive shape filter, not a denylist -- same reasoning as COLOR_TOKEN in
|
|
21
|
+
// extractClasses.ts. Excludes each utility's identity value (scale-100,
|
|
22
|
+
// rotate-0, translate-x-0/-y-0, skew-x-0/-y-0), since those utilities are
|
|
23
|
+
// real but move nothing -- flagging them would be a false positive, the
|
|
24
|
+
// same "shape looks real but isn't" failure class this project has hit
|
|
25
|
+
// before. Arbitrary bracket values (scale-[1.5]) and 3D transform utilities
|
|
26
|
+
// (rotate-x-*, translate-z-*, ...) are out of scope for v1 -- unmatched, so
|
|
27
|
+
// silently not considered "motion" here rather than guessed at.
|
|
28
|
+
const SCALE_RE = /^(scale|scale-x|scale-y)-(\d+)$/;
|
|
29
|
+
const ROTATE_RE = /^-?rotate-(\d+(?:\.\d+)?)$/;
|
|
30
|
+
const TRANSLATE_RE = /^-?(translate-x|translate-y)-(\d+(?:\.\d+)?)$/;
|
|
31
|
+
const SKEW_RE = /^-?(skew-x|skew-y)-(\d+(?:\.\d+)?)$/;
|
|
32
|
+
function isNonIdentityMotionUtility(base) {
|
|
33
|
+
const scale = SCALE_RE.exec(base);
|
|
34
|
+
if (scale)
|
|
35
|
+
return Number(scale[2]) !== 100;
|
|
36
|
+
const rotate = ROTATE_RE.exec(base);
|
|
37
|
+
if (rotate)
|
|
38
|
+
return Number(rotate[1]) !== 0;
|
|
39
|
+
const translate = TRANSLATE_RE.exec(base);
|
|
40
|
+
if (translate)
|
|
41
|
+
return Number(translate[2]) !== 0;
|
|
42
|
+
const skew = SKEW_RE.exec(base);
|
|
43
|
+
if (skew)
|
|
44
|
+
return Number(skew[2]) !== 0;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// `strict` gates the whole check for the scan-everything-by-default
|
|
48
|
+
// adapters (CLI/VS Code/GitHub Action) -- WCAG 2.3.3 is AAA-only, and
|
|
49
|
+
// unconditionally enabling a brand-new AAA check would silently start
|
|
50
|
+
// failing existing users' CI on a routine upgrade, unlike a genuine AA
|
|
51
|
+
// baseline (2.4.7's focus-indicator check, the one other check in this file
|
|
52
|
+
// with no strict tier). Consistent with this project's own `strict` =
|
|
53
|
+
// "hold every check to its AAA tier where one exists" framing. The ESLint
|
|
54
|
+
// rule is the one caller that always passes `true` regardless -- enabling
|
|
55
|
+
// `tailwind-a11y/reduced-motion` in an ESLint config is itself the opt-in
|
|
56
|
+
// gesture there, so a second gate on top would just make the rule silently
|
|
57
|
+
// report nothing when a user enabled it expecting it to check something.
|
|
58
|
+
export function checkReducedMotion(checks, strict = false) {
|
|
59
|
+
if (!strict)
|
|
60
|
+
return [];
|
|
61
|
+
const violations = [];
|
|
62
|
+
for (const check of checks) {
|
|
63
|
+
let unscopedTransition = null;
|
|
64
|
+
let hasMotionReduceGuard = false;
|
|
65
|
+
for (const raw of check.classes) {
|
|
66
|
+
const base = baseUtility(raw);
|
|
67
|
+
const segments = variantSegments(raw);
|
|
68
|
+
if (segments.length === 0 && TRANSITION_BASES.has(base))
|
|
69
|
+
unscopedTransition = raw;
|
|
70
|
+
if (segments.includes("motion-reduce") && (base === "transition-none" || base === "transform-none")) {
|
|
71
|
+
hasMotionReduceGuard = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// A transition scoped only under motion-safe: (never unscoped) means it
|
|
75
|
+
// simply doesn't exist unless motion is already safe -- a complete
|
|
76
|
+
// alternative way of satisfying 2.3.3, not a partial one -- so this
|
|
77
|
+
// correctly falls through as a pass, not a skip-because-unresolvable.
|
|
78
|
+
if (!unscopedTransition)
|
|
79
|
+
continue;
|
|
80
|
+
if (hasMotionReduceGuard)
|
|
81
|
+
continue;
|
|
82
|
+
const motionClass = check.classes.find((raw) => {
|
|
83
|
+
const segments = variantSegments(raw);
|
|
84
|
+
// motion-safe: anywhere in this specific class's own variant stack
|
|
85
|
+
// means the motion utility itself doesn't apply unless motion is
|
|
86
|
+
// already safe -- the same complete-alternative reasoning as the
|
|
87
|
+
// transition side above, just checked per-candidate instead of once
|
|
88
|
+
// for the whole element (a class can be interaction-scoped *and*
|
|
89
|
+
// self-guarded at the same time, e.g. `hover:motion-safe:scale-110`).
|
|
90
|
+
if (segments.includes("motion-safe"))
|
|
91
|
+
return false;
|
|
92
|
+
if (!segments.some((v) => INTERACTION_VARIANTS.has(v)))
|
|
93
|
+
return false;
|
|
94
|
+
return isNonIdentityMotionUtility(baseUtility(raw));
|
|
95
|
+
});
|
|
96
|
+
if (!motionClass)
|
|
97
|
+
continue; // no real, un-self-guarded motion actually triggered by interaction
|
|
98
|
+
violations.push({
|
|
99
|
+
type: "reduced-motion",
|
|
100
|
+
file: check.file,
|
|
101
|
+
line: check.line,
|
|
102
|
+
tagName: check.tagName,
|
|
103
|
+
transitionClass: unscopedTransition,
|
|
104
|
+
motionClass,
|
|
105
|
+
level: "AAA",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return violations;
|
|
109
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { hexToRgb } from "../contrast/luminance.js";
|
|
1
|
+
import { hexToRgb, rgbToHex } from "../contrast/luminance.js";
|
|
2
|
+
import { oklchToRgb } from "../contrast/oklch.js";
|
|
2
3
|
const SPACING_RE = /^-?[\d.]+(rem|px)$/;
|
|
3
4
|
export function parseSpacingValue(value) {
|
|
4
5
|
if (typeof value !== "string")
|
|
@@ -13,6 +14,19 @@ export function parseSpacingValue(value) {
|
|
|
13
14
|
// A flat string color (`brand: '#3490dc'`) or a `DEFAULT` key is skipped
|
|
14
15
|
// entirely -- there's no class syntax ("bg-brand-DEFAULT" isn't real Tailwind)
|
|
15
16
|
// that would ever resolve to it, so partially supporting it would be dead code.
|
|
17
|
+
// Resolves a shade value to the plain hex string every downstream consumer
|
|
18
|
+
// (resolveColorValue -> hexToRgb in checkContrast.ts/checkFocusIndicator.ts)
|
|
19
|
+
// already assumes every palette entry is. A hex value is stored as-is; an
|
|
20
|
+
// oklch() value is converted to hex once, here, so the conversion never has
|
|
21
|
+
// to be repeated (or reimplemented) at every place a palette color gets
|
|
22
|
+
// used. Anything else (rgb()/hsl()/var()/lab()/lch()/color()/...) is still
|
|
23
|
+
// skipped -- out of scope for now, same "don't guess" precedent as before.
|
|
24
|
+
function resolveShadeHex(shadeValue) {
|
|
25
|
+
if (hexToRgb(shadeValue) !== null)
|
|
26
|
+
return shadeValue;
|
|
27
|
+
const oklchRgb = oklchToRgb(shadeValue);
|
|
28
|
+
return oklchRgb ? rgbToHex(oklchRgb) : null;
|
|
29
|
+
}
|
|
16
30
|
export function parseColorScale(value) {
|
|
17
31
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
18
32
|
return null;
|
|
@@ -22,9 +36,10 @@ export function parseColorScale(value) {
|
|
|
22
36
|
continue; // no "bg-brand-DEFAULT" class syntax exists to resolve it
|
|
23
37
|
if (typeof shadeValue !== "string")
|
|
24
38
|
continue;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
const hex = resolveShadeHex(shadeValue);
|
|
40
|
+
if (hex === null)
|
|
41
|
+
continue; // neither hex nor oklch -- skip, don't guess
|
|
42
|
+
shades[shade] = hex;
|
|
28
43
|
}
|
|
29
44
|
return Object.keys(shades).length > 0 ? shades : null;
|
|
30
45
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "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.",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal/contrast — in Tailwind CSS class combinations before they ship.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tailwind-a11y": "./dist/cli.js"
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
"esbuild": "^0.28.0",
|
|
62
62
|
"tsx": "^4.19.0",
|
|
63
63
|
"typescript": "^5.5.0",
|
|
64
|
-
"vitest": "^
|
|
64
|
+
"vitest": "^4.1.11"
|
|
65
65
|
}
|
|
66
66
|
}
|