tailwind-a11y 0.10.0 → 0.13.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: WCAG 2.5.5 (AAA, 44x44px); focus indicators: also WCAG 2.4.13 (AAA, min thickness)
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
@@ -34,7 +34,7 @@ npx tailwind-a11y --help # usage and all options
34
34
  Custom theme colors/spacing are read automatically, so colors and spacing outside
35
35
  Tailwind's defaults resolve too — not just the built-in palette. Both config formats
36
36
  are supported: `theme.extend.colors`/`theme.extend.spacing` in a Tailwind v3
37
- `tailwind.config.js`/`.cjs`, and `--color-*`/`--spacing-*` custom properties in a
37
+ `tailwind.config.js`/`.cjs`/`.mjs`, and `--color-*`/`--spacing-*` custom properties in a
38
38
  Tailwind v4 CSS `@theme { ... }` block (auto-detected from common paths like
39
39
  `app/globals.css`, or passed via `--config`).
40
40
 
@@ -57,6 +57,7 @@ Exits `1` on violations — safe to use as a CI gate.
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
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 |
60
61
 
61
62
  ## Scope
62
63
 
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extr
9
9
  import { checkTouchTargets } from "./rules/checkTouchTarget.js";
10
10
  import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
11
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).
@@ -33,6 +35,8 @@ function formatViolation(v) {
33
35
  ? `${base}; also only ${v.thicknessPx}px thick, needs >= ${v.requiredThicknessPx}px`
34
36
  : base;
35
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`;
36
40
  }
37
41
  }
38
42
  function groupByFile(items) {
@@ -87,7 +91,7 @@ async function main() {
87
91
  const code = readFileSync(absPath, "utf8");
88
92
  const contrastChecks = extractChecks(code, file);
89
93
  const focusChecks = extractFocusIndicatorChecks(code, file);
90
- violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette));
94
+ violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette), ...checkReducedMotion(extractReducedMotionChecks(code, file), strict));
91
95
  if (verbose) {
92
96
  skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
93
97
  }
package/dist/cliArgs.js CHANGED
@@ -3,7 +3,8 @@
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/contrast.
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
@@ -12,7 +13,10 @@ Options:
12
13
  --strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
13
14
  instead of the default 2.5.8 (AA, 24x24px); focus
14
15
  indicators are also held to 2.4.13 (AAA, minimum
15
- thickness) alongside the default 1.4.11 (AA, contrast)
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)
16
20
  --config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
17
21
  @theme file like app/globals.css (v4) to read
18
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
@@ -4,6 +4,8 @@ export { extractTouchTargetChecks, extractTouchTargetSkips, type TouchTargetChec
4
4
  export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouchTarget.js";
5
5
  export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
6
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
@@ -4,6 +4,8 @@ export { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extr
4
4
  export { checkTouchTargets } from "./rules/checkTouchTarget.js";
5
5
  export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
6
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";
@@ -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
+ }
@@ -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
+ }
@@ -5,7 +5,10 @@ import { defaultPalette } from "./defaultPalette.js";
5
5
  import { spacingScale } from "./spacingScale.js";
6
6
  import { parseColorScale, parseSpacingValue } from "./themeValueParsers.js";
7
7
  import { parseThemeCss } from "./parseThemeCss.js";
8
- const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs"];
8
+ // .mjs appended last (lowest priority) -- the newly-supported format behind
9
+ // the two established ones, same "most established first" ordering
10
+ // CSS_THEME_CANDIDATES below already uses for its own list.
11
+ const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs"];
9
12
  // v1 only looks in the given directory itself -- no ancestor-directory search.
10
13
  // --config (CLI) / settings["tailwind-a11y"].configPath (ESLint) exist as
11
14
  // explicit escape hatches for projects where this isn't enough. `rootDir` must
@@ -42,15 +45,46 @@ export function findTailwindThemeCss(rootDir) {
42
45
  }
43
46
  return null;
44
47
  }
45
- // Loads a Tailwind v3-style tailwind.config.js/.cjs and extracts only
48
+ // Loads a Tailwind v3-style tailwind.config.js/.cjs/.mjs and extracts only
46
49
  // `theme.extend.colors`/`theme.extend.spacing` -- v1 does not read a full
47
- // `theme.colors`/`theme.spacing` replacement, or .mjs/.ts configs (no
48
- // config-transpiling dependency exists in this package). Tailwind v4's
50
+ // `theme.colors`/`theme.spacing` replacement, or .ts configs. Tailwind v4's
49
51
  // CSS-based `@theme` config is a separate format entirely, handled by
50
52
  // loadThemeFromCssFile()/parseThemeCss() below, not by this function.
51
53
  // `configPath` must be an absolute path (require() resolves relative paths
52
54
  // against this module's own location, not the caller's cwd).
53
55
  //
56
+ // .mjs works via plain require() -- verified this session that Node
57
+ // 20.19+/22.13+ can require() an ESM module synchronously, no import(), no
58
+ // async refactor. On an older Node this throws ERR_REQUIRE_ESM, already
59
+ // caught below and treated as "no config found," so this degrades exactly
60
+ // as gracefully as it did before .mjs was supported. Every adapter's actual
61
+ // runtime already clears the threshold: the GitHub Action runs on Node 24
62
+ // (action.yml), eslint-plugin-tailwind-a11y's own engines.node already
63
+ // excludes every version that lacks this, and the CLI's broad >=18 floor
64
+ // just falls back safely on anything older.
65
+ //
66
+ // .ts is a deliberate non-goal, not a "not yet": Node's native TypeScript
67
+ // type-stripping only activates when the *host* process is launched with
68
+ // --experimental-strip-types (verified this session -- a library can't
69
+ // turn this on for the user), so the only way to support .ts transparently
70
+ // would be promoting esbuild from a devDependency to a real runtime
71
+ // dependency of this package purely to transpile config files, a real
72
+ // native-binary weight increase. Also verified this session: a fresh
73
+ // `create-next-app --typescript --tailwind` no longer generates a JS/TS
74
+ // config file at all -- Tailwind v4 projects put theme customization in a
75
+ // CSS `@theme` block instead (see loadThemeFromCssFile() below), so .ts
76
+ // config support would only help a shrinking population of legacy
77
+ // v3-plus-TypeScript projects, not worth the dependency.
78
+ //
79
+ // Known limitation, not fixed: bustRequireCache() below does NOT work for
80
+ // a .mjs config. Node's synchronous require(esm) caches the module in its
81
+ // own internal ESM registry, not (only) in `require.cache` -- deleting the
82
+ // `require.cache` entry doesn't force a reload, confirmed with a real
83
+ // edit-and-reload test this session. CLI and GitHub Action are unaffected
84
+ // (fresh process per run either way); the VS Code extension's live-reload
85
+ // guarantee, which does work correctly for .js/.cjs/.css configs, does NOT
86
+ // extend to .mjs -- editing a .mjs config requires reloading the window.
87
+ //
54
88
  // Node's require() cache is busted before loading -- recursively, for the
55
89
  // config file *and* everything it required (e.g. a config that factors
56
90
  // tokens into a separate `require('./colors.js')`) -- without this, a
@@ -83,7 +117,20 @@ export function loadCustomTheme(configPath) {
83
117
  const cached = require.cache[resolved];
84
118
  if (cached)
85
119
  bustRequireCache(require, cached, new Set());
86
- const config = require(resolved);
120
+ const loaded = require(resolved);
121
+ // Node's require() of an ESM module returns the module namespace object
122
+ // (`{ __esModule: true, default: <the actual export>, ...named exports
123
+ // }`), not the export itself. Gated strictly on the .mjs extension --
124
+ // caught in independent review: a structural check ("does it have a
125
+ // `default` key") instead of this would silently misfire on a genuine
126
+ // CJS config that happens to export its own top-level `default` key
127
+ // (e.g. `module.exports = { default: "unrelated", theme: {...} }`),
128
+ // discarding the real theme with no error. .mjs is the only path that
129
+ // can ever produce this wrapped shape here: a `.js`/`.cjs` require()
130
+ // either returns the CJS export as-is, or -- inside a "type": "module"
131
+ // package -- throws ERR_REQUIRE_ESM before this line is ever reached
132
+ // (already handled by the catch block below, and already tested).
133
+ const config = resolved.endsWith(".mjs") && loaded && typeof loaded === "object" ? loaded.default : loaded;
87
134
  const extend = config?.theme?.extend ?? {};
88
135
  const result = {};
89
136
  if (extend.colors && typeof extend.colors === "object") {
@@ -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.10.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.13.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
  }