tailwind-a11y 0.3.1 → 0.5.0

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