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 +12 -1
- package/dist/src/cli.js +3 -1
- package/dist/src/core.d.ts +1 -0
- package/dist/src/core.js +1 -0
- package/dist/src/report.d.ts +1 -1
- package/dist/src/report.js +11 -1
- package/dist/src/suggest.d.ts +12 -0
- package/dist/src/suggest.js +119 -0
- package/dist/test/suggest.test.d.ts +1 -0
- package/dist/test/suggest.test.js +48 -0
- package/package.json +1 -1
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
|
}
|
package/dist/src/core.d.ts
CHANGED
|
@@ -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
package/dist/src/report.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/report.js
CHANGED
|
@@ -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
|
+
});
|