contrast-gate 1.0.0 → 1.1.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
@@ -59,8 +59,16 @@ Exit code is `1` if anything fails WCAG AA — which means it works as a CI gate
59
59
  |---|---|
60
60
  | `--json` | Print machine-readable JSON instead of a table |
61
61
  | `--aaa` | Require WCAG AAA (7:1 normal text) instead of AA (4.5:1) |
62
+ | `--fix` | For each failing pair, suggest a corrected color that would pass |
62
63
  | `-h, --help` | Show usage |
63
64
 
65
+ With `--fix`, a failing pair gets a suggested replacement color — the smaller of adjusting the foreground or the background, found by holding hue and saturation fixed and adjusting only lightness until the pair clears the threshold:
66
+
67
+ ```
68
+ - Muted caption: 1.68:1 (#c8c0ae on #faf6ee) — needs at least 4.5:1
69
+ fix: change text color to #7d7055 → 4.51:1
70
+ ```
71
+
64
72
  ### Exit codes
65
73
 
66
74
  | Code | Meaning |
@@ -94,11 +102,14 @@ jobs:
94
102
  The contrast-calculation core is exported directly, if you want to check colors from your own code rather than a config file:
95
103
 
96
104
  ```ts
97
- import { contrastRatioHex, checkCompliance } from "contrast-gate";
105
+ import { contrastRatioHex, checkCompliance, suggestFix } from "contrast-gate";
98
106
 
99
107
  const ratio = contrastRatioHex("#302a22", "#faf6ee"); // 13.16
100
108
  const result = checkCompliance(ratio);
101
109
  // { ratio: 13.16, passesAA: { normal: true, large: true }, passesAAA: { normal: true, large: true } }
110
+
111
+ const fix = suggestFix("#c8c0ae", "#faf6ee", 4.5);
112
+ // { fg: "#7d7055", bg: "#faf6ee", ratio: 4.51, adjusted: "fg" }
102
113
  ```
103
114
 
104
115
  ## How the math works
package/dist/src/cli.js CHANGED
@@ -11,6 +11,7 @@ Usage:
11
11
  Options:
12
12
  --json Output machine-readable JSON instead of a table
13
13
  --aaa Require WCAG AAA (7:1 normal text) instead of AA (4.5:1) to pass
14
+ --fix For each failing pair, suggest a corrected color that would pass
14
15
  -h, --help Show this help message
15
16
 
16
17
  Example:
@@ -36,6 +37,7 @@ async function main() {
36
37
  options: {
37
38
  json: { type: "boolean", default: false },
38
39
  aaa: { type: "boolean", default: false },
40
+ fix: { type: "boolean", default: false },
39
41
  help: { type: "boolean", short: "h", default: false },
40
42
  },
41
43
  allowPositionals: true,
@@ -63,7 +65,7 @@ async function main() {
63
65
  renderJson(results);
64
66
  }
65
67
  else {
66
- renderTable(results);
68
+ renderTable(results, values.fix);
67
69
  }
68
70
  process.exit(allPass ? 0 : 1);
69
71
  }
@@ -43,3 +43,4 @@ export interface PairResult extends ComplianceResult {
43
43
  }
44
44
  /** Evaluates a named list of foreground/background pairs in one pass. */
45
45
  export declare function evaluatePairs(pairs: ColorPair[]): PairResult[];
46
+ export { suggestFix, type FixSuggestion } from "./suggest.js";
package/dist/src/core.js CHANGED
@@ -69,3 +69,4 @@ export function evaluatePairs(pairs) {
69
69
  return { ...pair, ...checkCompliance(ratio) };
70
70
  });
71
71
  }
72
+ export { suggestFix } from "./suggest.js";
@@ -1,5 +1,5 @@
1
1
  import type { PairResult } from "./core.js";
2
2
  /** Renders a human-readable colored table to stdout. */
3
- export declare function renderTable(results: PairResult[]): void;
3
+ export declare function renderTable(results: PairResult[], showFixes?: boolean): void;
4
4
  /** Renders results as machine-readable JSON to stdout. */
5
5
  export declare function renderJson(results: PairResult[]): void;
@@ -1,3 +1,4 @@
1
+ import { suggestFix } from "./suggest.js";
1
2
  const RESET = "\x1b[0m";
2
3
  const GREEN = "\x1b[32m";
3
4
  const RED = "\x1b[31m";
@@ -10,7 +11,7 @@ function badge(pass) {
10
11
  return pass ? `${GREEN}PASS${RESET}` : `${RED}FAIL${RESET}`;
11
12
  }
12
13
  /** Renders a human-readable colored table to stdout. */
13
- export function renderTable(results) {
14
+ export function renderTable(results, showFixes = false) {
14
15
  const nameWidth = Math.max(4, ...results.map((r) => r.name.length));
15
16
  console.log(`${BOLD}${pad("Pair", nameWidth)} Ratio AA Normal AA Large AAA Normal AAA Large${RESET}`);
16
17
  console.log(DIM + "-".repeat(nameWidth + 58) + RESET);
@@ -27,6 +28,15 @@ export function renderTable(results) {
27
28
  console.log(`${RED}${failing.length} of ${results.length} pair(s) fail WCAG AA (normal text):${RESET}`);
28
29
  for (const f of failing) {
29
30
  console.log(` - ${f.name}: ${f.ratio.toFixed(2)}:1 (${f.fg} on ${f.bg}) — needs at least 4.5:1`);
31
+ if (showFixes) {
32
+ const fix = suggestFix(f.fg, f.bg, 4.5);
33
+ if (fix.adjusted === "fg") {
34
+ console.log(` ${DIM}fix: change text color to ${fix.fg} → ${fix.ratio.toFixed(2)}:1${RESET}`);
35
+ }
36
+ else if (fix.adjusted === "bg") {
37
+ console.log(` ${DIM}fix: change background to ${fix.bg} → ${fix.ratio.toFixed(2)}:1${RESET}`);
38
+ }
39
+ }
30
40
  }
31
41
  }
32
42
  }
@@ -0,0 +1,12 @@
1
+ export interface FixSuggestion {
2
+ fg: string;
3
+ bg: string;
4
+ ratio: number;
5
+ adjusted: "fg" | "bg" | "none";
6
+ }
7
+ /**
8
+ * Suggests the smallest lightness adjustment (to either fg or bg) that reaches
9
+ * targetRatio, preserving hue and saturation so the result still looks related
10
+ * to the original color. Picks whichever of fg/bg needs the smaller change.
11
+ */
12
+ export declare function suggestFix(fgHex: string, bgHex: string, targetRatio?: number): FixSuggestion;
@@ -0,0 +1,119 @@
1
+ import { parseHexColor, contrastRatio } from "./core.js";
2
+ function rgbToHsl({ r, g, b }) {
3
+ const rn = r / 255;
4
+ const gn = g / 255;
5
+ const bn = b / 255;
6
+ const max = Math.max(rn, gn, bn);
7
+ const min = Math.min(rn, gn, bn);
8
+ const l = (max + min) / 2;
9
+ if (max === min)
10
+ return { h: 0, s: 0, l: l * 100 };
11
+ const d = max - min;
12
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
13
+ let h;
14
+ switch (max) {
15
+ case rn:
16
+ h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
17
+ break;
18
+ case gn:
19
+ h = ((bn - rn) / d + 2) / 6;
20
+ break;
21
+ default:
22
+ h = ((rn - gn) / d + 4) / 6;
23
+ }
24
+ return { h: h * 360, s: s * 100, l: l * 100 };
25
+ }
26
+ function hueToRgb(p, q, t) {
27
+ let tt = t;
28
+ if (tt < 0)
29
+ tt += 1;
30
+ if (tt > 1)
31
+ tt -= 1;
32
+ if (tt < 1 / 6)
33
+ return p + (q - p) * 6 * tt;
34
+ if (tt < 1 / 2)
35
+ return q;
36
+ if (tt < 2 / 3)
37
+ return p + (q - p) * (2 / 3 - tt) * 6;
38
+ return p;
39
+ }
40
+ function hslToRgb({ h, s, l }) {
41
+ const hn = h / 360;
42
+ const sn = s / 100;
43
+ const ln = l / 100;
44
+ if (sn === 0) {
45
+ const v = Math.round(ln * 255);
46
+ return { r: v, g: v, b: v };
47
+ }
48
+ const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;
49
+ const p = 2 * ln - q;
50
+ return {
51
+ r: Math.round(hueToRgb(p, q, hn + 1 / 3) * 255),
52
+ g: Math.round(hueToRgb(p, q, hn) * 255),
53
+ b: Math.round(hueToRgb(p, q, hn - 1 / 3) * 255),
54
+ };
55
+ }
56
+ function rgbToHex({ r, g, b }) {
57
+ const toHex = (n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
58
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
59
+ }
60
+ /**
61
+ * Binary-searches lightness for `moving`, holding hue/saturation fixed, to find
62
+ * the smallest lightness change (in the direction away from `fixed`'s lightness)
63
+ * that reaches targetRatio against `fixed`. Returns null if even pure black/white
64
+ * can't reach the target (fixed is fundamentally too close to mid-gray... rare).
65
+ */
66
+ function searchLightness(moving, fixed, targetRatio) {
67
+ const movingHsl = rgbToHsl(moving);
68
+ const fixed_l = rgbToHsl(fixed).l;
69
+ const original = movingHsl.l;
70
+ // To increase contrast, push `moving` further away from `fixed`'s lightness:
71
+ // if moving is already darker, the extreme to move toward is pure black; if lighter, pure white.
72
+ const extreme = original <= fixed_l ? 0 : 100;
73
+ const lightnessAt = (t) => original + t * (extreme - original); // t=0 -> original, t=1 -> extreme
74
+ const ratioAt = (t) => contrastRatio(hslToRgb({ ...movingHsl, l: lightnessAt(t) }), fixed);
75
+ if (ratioAt(1) < targetRatio)
76
+ return null; // even the extreme doesn't reach target
77
+ // ratioAt(t) is monotonically increasing in t. Find the minimal t (least change
78
+ // from the original color) that still clears the target -- a simple boundary search.
79
+ let lo = 0; // presumed not to satisfy (we only call this when currently failing)
80
+ let hi = 1; // verified above to satisfy
81
+ for (let i = 0; i < 40; i++) {
82
+ const mid = (lo + hi) / 2;
83
+ if (ratioAt(mid) >= targetRatio)
84
+ hi = mid;
85
+ else
86
+ lo = mid;
87
+ }
88
+ return hslToRgb({ ...movingHsl, l: lightnessAt(hi) });
89
+ }
90
+ /**
91
+ * Suggests the smallest lightness adjustment (to either fg or bg) that reaches
92
+ * targetRatio, preserving hue and saturation so the result still looks related
93
+ * to the original color. Picks whichever of fg/bg needs the smaller change.
94
+ */
95
+ export function suggestFix(fgHex, bgHex, targetRatio = 4.5) {
96
+ const fg = parseHexColor(fgHex);
97
+ const bg = parseHexColor(bgHex);
98
+ const currentRatio = contrastRatio(fg, bg);
99
+ if (currentRatio >= targetRatio) {
100
+ return { fg: fgHex, bg: bgHex, ratio: currentRatio, adjusted: "none" };
101
+ }
102
+ const fgFix = searchLightness(fg, bg, targetRatio);
103
+ const bgFix = searchLightness(bg, fg, targetRatio);
104
+ const fgDelta = fgFix ? Math.abs(rgbToHsl(fgFix).l - rgbToHsl(fg).l) : Infinity;
105
+ const bgDelta = bgFix ? Math.abs(rgbToHsl(bgFix).l - rgbToHsl(bg).l) : Infinity;
106
+ if (fgFix && fgDelta <= bgDelta) {
107
+ return { fg: rgbToHex(fgFix), bg: bgHex, ratio: contrastRatio(fgFix, bg), adjusted: "fg" };
108
+ }
109
+ if (bgFix) {
110
+ return { fg: fgHex, bg: rgbToHex(bgFix), ratio: contrastRatio(fg, bgFix), adjusted: "bg" };
111
+ }
112
+ // Neither could reach target (only possible if fixed color is itself ~middle gray
113
+ // at an unreasonably high target ratio) -- return best-effort fg=black/white flip.
114
+ const blackRatio = contrastRatio({ r: 0, g: 0, b: 0 }, bg);
115
+ const whiteRatio = contrastRatio({ r: 255, g: 255, b: 255 }, bg);
116
+ return blackRatio >= whiteRatio
117
+ ? { fg: "#000000", bg: bgHex, ratio: blackRatio, adjusted: "fg" }
118
+ : { fg: "#ffffff", bg: bgHex, ratio: whiteRatio, adjusted: "fg" };
119
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { contrastRatioHex } from "../src/core.js";
4
+ import { suggestFix } from "../src/suggest.js";
5
+ test("suggestFix: a pair that already passes is returned unchanged", () => {
6
+ const result = suggestFix("#000000", "#ffffff", 4.5);
7
+ assert.equal(result.adjusted, "none");
8
+ assert.equal(result.fg, "#000000");
9
+ assert.equal(result.bg, "#ffffff");
10
+ });
11
+ test("suggestFix: the real failing example from our own README (1.68:1) gets fixed to actually pass", () => {
12
+ const before = contrastRatioHex("#c8c0ae", "#faf6ee");
13
+ assert.ok(before < 4.5, "sanity check: this pair should actually be failing before the fix");
14
+ const fix = suggestFix("#c8c0ae", "#faf6ee", 4.5);
15
+ assert.ok(fix.ratio >= 4.5, `suggested fix should reach the target, got ${fix.ratio}`);
16
+ // The suggestion must be independently re-verifiable, not just self-reported.
17
+ const actualRatio = contrastRatioHex(fix.fg, fix.bg);
18
+ assert.ok(Math.abs(actualRatio - fix.ratio) < 0.01, `suggested colors, when independently re-checked, should match the reported ratio`);
19
+ });
20
+ test("suggestFix: picks whichever of fg/bg needs the smaller lightness change", () => {
21
+ // #1a1a1a (L~10%) against #999999 (L~60%) at a demanding AAA target (7:1):
22
+ // pushing fg toward black has very little room left to work with, while bg
23
+ // has a wide range to move into. The smaller actual change is on bg's side --
24
+ // verified by computing both deltas directly, not assumed.
25
+ const fix = suggestFix("#1a1a1a", "#999999", 7);
26
+ assert.equal(fix.adjusted, "bg");
27
+ assert.ok(fix.ratio >= 7);
28
+ });
29
+ test("suggestFix: works with AAA target too, not just the AA default", () => {
30
+ const fix = suggestFix("#767676", "#ffffff", 7); // 4.54:1 passes AA but not AAA
31
+ assert.ok(fix.ratio >= 7, `expected AAA-level fix, got ${fix.ratio}`);
32
+ });
33
+ test("suggestFix: never returns a suggestion that fails to meet the target when re-verified", () => {
34
+ // Run across a spread of failing pairs and confirm every single suggestion
35
+ // actually holds up when independently recomputed -- not just trusted.
36
+ const cases = [
37
+ ["#aaaaaa", "#ffffff"],
38
+ ["#333333", "#000000"],
39
+ ["#c8c0ae", "#faf6ee"],
40
+ ["#ff0000", "#ff8080"],
41
+ ];
42
+ for (const [fg, bg] of cases) {
43
+ const fix = suggestFix(fg, bg, 4.5);
44
+ const reVerified = contrastRatioHex(fix.fg, fix.bg);
45
+ assert.ok(reVerified >= 4.49, // tiny float tolerance
46
+ `${fg}/${bg} -> suggested ${fix.fg}/${fix.bg} re-verifies to ${reVerified}, expected >= 4.5`);
47
+ }
48
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contrast-gate",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Audit an entire design-token file for WCAG contrast compliance in one command, and gate CI on it.",
5
5
  "type": "module",
6
6
  "bin": {