tailwind-a11y 0.4.0 → 0.5.1
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
CHANGED
|
@@ -50,7 +50,7 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
50
50
|
|
|
51
51
|
| Check | WCAG | Detects |
|
|
52
52
|
|---|---|---|
|
|
53
|
-
| 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 |
|
|
54
54
|
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px |
|
|
55
55
|
| Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
|
|
56
56
|
|
|
@@ -63,7 +63,10 @@ When a case can't be resolved with confidence, it's skipped rather than guessed:
|
|
|
63
63
|
- A full `theme.colors`/`theme.spacing` replacement, `.mjs`/`.ts` configs, or
|
|
64
64
|
Tailwind v4's CSS-based `@theme` config (only `theme.extend` in a `.js`/`.cjs`
|
|
65
65
|
config is read — see [CLAUDE.md](./CLAUDE.md))
|
|
66
|
-
-
|
|
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.
|
|
67
70
|
- Frameworks other than React/JSX
|
|
68
71
|
|
|
69
72
|
`--verbose` reports what was skipped and why — a skip is not a pass. Full rationale in
|
|
@@ -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;
|
|
@@ -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
|
|
@@ -1,4 +1,4 @@
|
|
|
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
3
|
export function resolveColorValue(utilityClass, palette = defaultPalette) {
|
|
4
4
|
const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
|
|
@@ -19,16 +19,50 @@ export function resolveColorValue(utilityClass, palette = defaultPalette) {
|
|
|
19
19
|
return null;
|
|
20
20
|
return palette[scale]?.[shade] ?? null; // unknown/custom color — skip
|
|
21
21
|
}
|
|
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
|
+
}
|
|
22
55
|
export function checkContrast(checks, palette = defaultPalette) {
|
|
23
56
|
const violations = [];
|
|
24
57
|
for (const check of checks) {
|
|
25
|
-
const textHex = resolveColorValue(check.textColorClass, palette);
|
|
26
58
|
const bgHex = resolveColorValue(check.bgColorClass, palette);
|
|
27
|
-
if (!
|
|
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
|
|
@@ -50,18 +84,22 @@ export function checkContrast(checks, palette = defaultPalette) {
|
|
|
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
|
-
//
|
|
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.
|
|
61
96
|
export function suggestContrastFix(textClass, bgClass, required, palette = defaultPalette) {
|
|
62
|
-
const
|
|
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
104
|
const shades = palette[scale];
|
|
67
105
|
if (!shades?.[shade])
|
|
@@ -75,34 +113,64 @@ export function suggestContrastFix(textClass, bgClass, required, palette = defau
|
|
|
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.
|
|
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.
|
|
93
144
|
export function checkContrastValueSkips(checks, palette = defaultPalette) {
|
|
94
145
|
const skips = [];
|
|
95
146
|
for (const check of checks) {
|
|
96
|
-
const textHex = resolveColorValue(check.textColorClass, palette);
|
|
97
147
|
const bgHex = resolveColorValue(check.bgColorClass, palette);
|
|
98
|
-
|
|
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
|
}
|
|
@@ -73,7 +73,14 @@ function bustRequireCache(require, mod, seen) {
|
|
|
73
73
|
// spacing returns {}, which callers must not treat as an error.
|
|
74
74
|
export function loadCustomTheme(configPath) {
|
|
75
75
|
try {
|
|
76
|
-
|
|
76
|
+
// createRequire is anchored to the config file itself, NOT import.meta.url:
|
|
77
|
+
// esbuild's CJS output (the VS Code and GitHub Action bundles) rewrites
|
|
78
|
+
// import.meta to an empty object, so createRequire(import.meta.url) throws
|
|
79
|
+
// inside a bundle -- and this function's own try/catch would swallow that
|
|
80
|
+
// into a silent "no config found" fallback. configPath is documented as
|
|
81
|
+
// absolute, which is exactly what createRequire needs as an anchor, and it
|
|
82
|
+
// also makes relative require()s inside the config resolve correctly.
|
|
83
|
+
const require = createRequire(configPath);
|
|
77
84
|
const resolved = require.resolve(configPath);
|
|
78
85
|
const cached = require.cache[resolved];
|
|
79
86
|
if (cached)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/babel__traverse": "^7.20.6",
|
|
60
60
|
"@types/node": "^22.0.0",
|
|
61
|
+
"esbuild": "^0.28.0",
|
|
61
62
|
"tsx": "^4.19.0",
|
|
62
63
|
"typescript": "^5.5.0",
|
|
63
64
|
"vitest": "^2.1.0"
|