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 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 must meet WCAG 2.5.5 (AAA, 44x44px), not just 2.5.8 (AA, 24x24px)
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
- violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
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, and focus indicator removal.
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,2 @@
1
+ import type { RGB } from "./luminance.js";
2
+ export declare function oklchToRgb(value: string): RGB | null;
@@ -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,7 @@
1
+ export interface ReducedMotionCheck {
2
+ file: string;
3
+ line: number;
4
+ tagName: string;
5
+ classes: string[];
6
+ }
7
+ export declare function extractReducedMotionChecks(code: string, filePath: string): ReducedMotionCheck[];
@@ -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
- const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
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
- if (hexToRgb(shadeValue) === null)
26
- continue; // not a hex value -- skip, don't guess
27
- shades[shade] = shadeValue;
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.9.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": "^2.1.0"
64
+ "vitest": "^4.1.11"
65
65
  }
66
66
  }