@bounded-systems/conformance-kit 0.2.0 → 0.4.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 +4 -2
- package/gates/jargon-gate.mjs +159 -0
- package/gates/palette-gate.mjs +601 -0
- package/integrity/verify/package-lock.json +1207 -0
- package/integrity/verify/package.json +10 -0
- package/integrity/verify/verify.mjs +8 -3
- package/package.json +22 -19
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Palette gate — STATIC colour-palette analysis over a site/brand's design tokens.
|
|
3
|
+
// Three high-value checks that a per-page a11y scan (axe) cannot do, because they
|
|
4
|
+
// reason about the PALETTE itself — the colour math, not the rendered DOM:
|
|
5
|
+
//
|
|
6
|
+
// 1. CVD-SAFE CONTRAST — simulate every used colour under deuteranopia /
|
|
7
|
+
// protanopia / tritanopia (Machado-2009 matrices), recompute WCAG contrast
|
|
8
|
+
// for each declared pair under each CVD type, and FAIL any pair that drops
|
|
9
|
+
// below AA for someone with that colour-vision deficiency. Also flag
|
|
10
|
+
// CATEGORICAL colours that COLLAPSE (become indistinguishable, CIEDE2000 ΔE
|
|
11
|
+
// below a threshold) once a CVD transform is applied.
|
|
12
|
+
// 2. APCA — the perceptual contrast metric (APCA-W3 ~0.1.9) the next WCAG (WCAG 3
|
|
13
|
+
// / "Silver") is built around. Compute Lc per text pair, check against a
|
|
14
|
+
// font-size/weight-aware minimum (or the documented baseline floor), and
|
|
15
|
+
// report BOTH the APCA Lc AND the WCAG-2 ratio — complement, not replacement.
|
|
16
|
+
// 3. NON-TEXT CONTRAST (WCAG 2.2 SC 1.4.11) — UI pairs (borders, focus rings,
|
|
17
|
+
// icon glyphs, control boundaries) require ≥ 3:1 against what they sit on.
|
|
18
|
+
//
|
|
19
|
+
// Zero-dependency: every colour-science primitive (sRGB→linear, relative
|
|
20
|
+
// luminance, WCAG ratio, CIE Lab, CIEDE2000, APCA-W3, the CVD matrices) is
|
|
21
|
+
// computed here by hand and CITED in-line, exactly like the kit's other gates.
|
|
22
|
+
//
|
|
23
|
+
// node gates/palette-gate.mjs [tokens] [pairings] # build gate (exit 1 on any failure)
|
|
24
|
+
//
|
|
25
|
+
// INPUTS the consumer supplies (nothing about any one brand is hard-coded):
|
|
26
|
+
// argv[2] / $PALETTE_TOKENS a token map: a DTCG `tokens.json` (primitive →
|
|
27
|
+
// semantic aliases resolved) OR a `tokens.css`
|
|
28
|
+
// (`--name: #hex;` custom properties).
|
|
29
|
+
// argv[3] / $PALETTE_PAIRINGS a `pairings.json` the consumer authors, declaring
|
|
30
|
+
// the fg/bg pairs that actually CO-OCCUR in the UI:
|
|
31
|
+
// { "thresholds": { … optional overrides … },
|
|
32
|
+
// "pairings": [ { "fg","bg","kind","size?","weight?","name?" } ],
|
|
33
|
+
// "categorical": [ "tokenA","tokenB", … ] }
|
|
34
|
+
// `kind` ∈ text | large-text | ui. `fg`/`bg` are a
|
|
35
|
+
// token name (resolved from the map) or a literal #hex.
|
|
36
|
+
// `categorical` = colours that must stay mutually
|
|
37
|
+
// distinguishable (chart series, status colours, …).
|
|
38
|
+
// $PALETTE_REPORT path to write the machine-readable JSON report.
|
|
39
|
+
//
|
|
40
|
+
// Thresholds are config-driven (pairings.json `thresholds` ⊕ env) and FAIL CLOSED:
|
|
41
|
+
// $PALETTE_MIN_RATIO_TEXT (default 4.5) WCAG AA, normal text (SC 1.4.3)
|
|
42
|
+
// $PALETTE_MIN_RATIO_LARGE (default 3.0) WCAG AA, large text (SC 1.4.3)
|
|
43
|
+
// $PALETTE_MIN_RATIO_UI (default 3.0) non-text contrast (SC 1.4.11)
|
|
44
|
+
// $PALETTE_MIN_LC_TEXT (default 60) APCA baseline, body
|
|
45
|
+
// $PALETTE_MIN_LC_LARGE (default 45) APCA baseline, large/headline
|
|
46
|
+
// $PALETTE_COLLAPSE_DELTAE (default 10) min CIEDE2000 ΔE between categorical
|
|
47
|
+
// colours after a CVD transform
|
|
48
|
+
//
|
|
49
|
+
// The pure functions are exported for unit testing; the CLI is a thin wrapper.
|
|
50
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
51
|
+
import { resolve } from "node:path";
|
|
52
|
+
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// 1. sRGB ↔ linear, relative luminance, WCAG-2 contrast ratio
|
|
55
|
+
// Refs: WCAG 2.2 — "relative luminance" + "contrast ratio" definitions
|
|
56
|
+
// https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
|
|
57
|
+
// https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio
|
|
58
|
+
// IEC 61966-2-1:1999 (sRGB) transfer function.
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Parse a #rgb / #rrggbb (or bare-hex) string → [r,g,b] in 0..255. */
|
|
62
|
+
export function parseHex(s) {
|
|
63
|
+
let h = String(s).trim().replace(/^#/, "");
|
|
64
|
+
if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split("").map((c) => c + c).join("");
|
|
65
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) throw new Error(`not a hex colour: "${s}"`);
|
|
66
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** [r,g,b] 0..255 → "#rrggbb" (clamped + rounded). */
|
|
70
|
+
export function toHex(rgb) {
|
|
71
|
+
return "#" + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** sRGB channel 0..255 → linear-light 0..1. WCAG/IEC 61966-2-1 transfer fn. */
|
|
75
|
+
export function srgbToLinear(c) {
|
|
76
|
+
const cs = c / 255;
|
|
77
|
+
return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** linear-light 0..1 → sRGB channel 0..255 (inverse transfer fn). */
|
|
81
|
+
export function linearToSrgb(c) {
|
|
82
|
+
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
83
|
+
return Math.max(0, Math.min(255, v * 255));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** WCAG relative luminance of an sRGB [r,g,b]. L = 0.2126R+0.7152G+0.0722B (linear). */
|
|
87
|
+
export function relativeLuminance(rgb) {
|
|
88
|
+
const [r, g, b] = rgb.map(srgbToLinear);
|
|
89
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** WCAG-2 contrast ratio of two sRGB colours: (L_light+0.05)/(L_dark+0.05), 1..21. */
|
|
93
|
+
export function wcagContrast(a, b) {
|
|
94
|
+
const la = relativeLuminance(a), lb = relativeLuminance(b);
|
|
95
|
+
const hi = Math.max(la, lb), lo = Math.min(la, lb);
|
|
96
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// 2. CIE Lab + CIEDE2000 colour difference
|
|
101
|
+
// Refs: sRGB→XYZ (D65) matrix, IEC 61966-2-1 / Bruce Lindbloom.
|
|
102
|
+
// CIEDE2000: Sharma, Wu, Dalal (2005), "The CIEDE2000 Color-Difference
|
|
103
|
+
// Formula: Implementation Notes, Supplementary Test Data, and Mathematical
|
|
104
|
+
// Observations", Color Res. Appl. 30(1). http://www.ece.rochester.edu/~gsharma/ciede2000/
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/** sRGB [r,g,b] 0..255 → CIE L*a*b* (D65 reference white). */
|
|
108
|
+
export function rgbToLab(rgb) {
|
|
109
|
+
const [r, g, b] = rgb.map(srgbToLinear);
|
|
110
|
+
// linear sRGB → XYZ (D65), IEC 61966-2-1 / sRGB→XYZ matrix.
|
|
111
|
+
const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
|
|
112
|
+
const Y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
|
|
113
|
+
const Z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b;
|
|
114
|
+
// D65 reference white.
|
|
115
|
+
const Xn = 0.95047, Yn = 1.0, Zn = 1.08883;
|
|
116
|
+
const e = 216 / 24389, k = 24389 / 27; // CIE standard ε, κ
|
|
117
|
+
const f = (t) => (t > e ? Math.cbrt(t) : (k * t + 16) / 116);
|
|
118
|
+
const fx = f(X / Xn), fy = f(Y / Yn), fz = f(Z / Zn);
|
|
119
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** CIEDE2000 ΔE between two CIE Lab colours (Sharma et al. 2005 reference impl). */
|
|
123
|
+
export function ciede2000(lab1, lab2) {
|
|
124
|
+
const [L1, a1, b1] = lab1, [L2, a2, b2] = lab2;
|
|
125
|
+
const rad = Math.PI / 180, deg = 180 / Math.PI;
|
|
126
|
+
const kL = 1, kC = 1, kH = 1;
|
|
127
|
+
const C1 = Math.hypot(a1, b1), C2 = Math.hypot(a2, b2);
|
|
128
|
+
const Cbar = (C1 + C2) / 2;
|
|
129
|
+
const Cbar7 = Math.pow(Cbar, 7);
|
|
130
|
+
const G = 0.5 * (1 - Math.sqrt(Cbar7 / (Cbar7 + Math.pow(25, 7))));
|
|
131
|
+
const a1p = (1 + G) * a1, a2p = (1 + G) * a2;
|
|
132
|
+
const C1p = Math.hypot(a1p, b1), C2p = Math.hypot(a2p, b2);
|
|
133
|
+
const hp = (b, ap) => {
|
|
134
|
+
if (b === 0 && ap === 0) return 0;
|
|
135
|
+
let h = Math.atan2(b, ap) * deg;
|
|
136
|
+
return h < 0 ? h + 360 : h;
|
|
137
|
+
};
|
|
138
|
+
const h1p = hp(b1, a1p), h2p = hp(b2, a2p);
|
|
139
|
+
const dLp = L2 - L1;
|
|
140
|
+
const dCp = C2p - C1p;
|
|
141
|
+
let dhp;
|
|
142
|
+
if (C1p * C2p === 0) dhp = 0;
|
|
143
|
+
else {
|
|
144
|
+
dhp = h2p - h1p;
|
|
145
|
+
if (dhp > 180) dhp -= 360;
|
|
146
|
+
else if (dhp < -180) dhp += 360;
|
|
147
|
+
}
|
|
148
|
+
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * rad) / 2);
|
|
149
|
+
const Lbarp = (L1 + L2) / 2;
|
|
150
|
+
const Cbarp = (C1p + C2p) / 2;
|
|
151
|
+
let hbarp;
|
|
152
|
+
if (C1p * C2p === 0) hbarp = h1p + h2p;
|
|
153
|
+
else if (Math.abs(h1p - h2p) <= 180) hbarp = (h1p + h2p) / 2;
|
|
154
|
+
else hbarp = h1p + h2p < 360 ? (h1p + h2p + 360) / 2 : (h1p + h2p - 360) / 2;
|
|
155
|
+
const T =
|
|
156
|
+
1 -
|
|
157
|
+
0.17 * Math.cos((hbarp - 30) * rad) +
|
|
158
|
+
0.24 * Math.cos(2 * hbarp * rad) +
|
|
159
|
+
0.32 * Math.cos((3 * hbarp + 6) * rad) -
|
|
160
|
+
0.20 * Math.cos((4 * hbarp - 63) * rad);
|
|
161
|
+
const dTheta = 30 * Math.exp(-Math.pow((hbarp - 275) / 25, 2));
|
|
162
|
+
const Cbarp7 = Math.pow(Cbarp, 7);
|
|
163
|
+
const RC = 2 * Math.sqrt(Cbarp7 / (Cbarp7 + Math.pow(25, 7)));
|
|
164
|
+
const SL = 1 + (0.015 * Math.pow(Lbarp - 50, 2)) / Math.sqrt(20 + Math.pow(Lbarp - 50, 2));
|
|
165
|
+
const SC = 1 + 0.045 * Cbarp;
|
|
166
|
+
const SH = 1 + 0.015 * Cbarp * T;
|
|
167
|
+
const RT = -Math.sin(2 * dTheta * rad) * RC;
|
|
168
|
+
return Math.sqrt(
|
|
169
|
+
Math.pow(dLp / (kL * SL), 2) +
|
|
170
|
+
Math.pow(dCp / (kC * SC), 2) +
|
|
171
|
+
Math.pow(dHp / (kH * SH), 2) +
|
|
172
|
+
RT * (dCp / (kC * SC)) * (dHp / (kH * SH)),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// 3. APCA-W3 (Accessible Perceptual Contrast Algorithm), constants ~0.1.9 / 0.98G-4g.
|
|
178
|
+
// Ref: Andrew Somers (Myndex), APCA-W3 / SAPC — https://github.com/Myndex/apca-w3
|
|
179
|
+
// and https://github.com/Myndex/SAPC-APCA . Output `Lc` is a polarity-signed
|
|
180
|
+
// "lightness contrast" roughly in [-108, 106]; conformance uses |Lc|.
|
|
181
|
+
// APCA is a CANDIDATE method (WCAG 3 / "Silver"), reported ALONGSIDE — not in
|
|
182
|
+
// place of — the WCAG-2 ratio.
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
const SA98G = {
|
|
186
|
+
mainTRC: 2.4,
|
|
187
|
+
sRco: 0.2126729, sGco: 0.7151522, sBco: 0.0721750,
|
|
188
|
+
normBG: 0.56, normTXT: 0.57, revTXT: 0.62, revBG: 0.65,
|
|
189
|
+
blkThrs: 0.022, blkClmp: 1.414,
|
|
190
|
+
scaleBoW: 1.14, scaleWoB: 1.14,
|
|
191
|
+
loBoWoffset: 0.027, loWoBoffset: 0.027,
|
|
192
|
+
deltaYmin: 0.0005, loClip: 0.1,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** sRGB [r,g,b] 0..255 → APCA screen luminance Ys (simple power-curve, not WCAG luminance). */
|
|
196
|
+
export function apcaY(rgb) {
|
|
197
|
+
const s = SA98G;
|
|
198
|
+
return (
|
|
199
|
+
s.sRco * Math.pow(rgb[0] / 255, s.mainTRC) +
|
|
200
|
+
s.sGco * Math.pow(rgb[1] / 255, s.mainTRC) +
|
|
201
|
+
s.sBco * Math.pow(rgb[2] / 255, s.mainTRC)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** APCA-W3 contrast Lc for text-luminance over background-luminance. */
|
|
206
|
+
export function apcaContrastY(txtY, bgY) {
|
|
207
|
+
const s = SA98G;
|
|
208
|
+
if (Math.min(txtY, bgY) < 0) return 0.0;
|
|
209
|
+
// Soft-clamp near-black (low-luminance offset).
|
|
210
|
+
if (txtY < s.blkThrs) txtY += Math.pow(s.blkThrs - txtY, s.blkClmp);
|
|
211
|
+
if (bgY < s.blkThrs) bgY += Math.pow(s.blkThrs - bgY, s.blkClmp);
|
|
212
|
+
if (Math.abs(bgY - txtY) < s.deltaYmin) return 0.0;
|
|
213
|
+
let out;
|
|
214
|
+
if (bgY > txtY) {
|
|
215
|
+
// normal polarity: dark text on light bg
|
|
216
|
+
const SAPC = (Math.pow(bgY, s.normBG) - Math.pow(txtY, s.normTXT)) * s.scaleBoW;
|
|
217
|
+
out = SAPC < s.loClip ? 0.0 : SAPC - s.loBoWoffset;
|
|
218
|
+
} else {
|
|
219
|
+
// reverse polarity: light text on dark bg
|
|
220
|
+
const SAPC = (Math.pow(bgY, s.revBG) - Math.pow(txtY, s.revTXT)) * s.scaleWoB;
|
|
221
|
+
out = SAPC > -s.loClip ? 0.0 : SAPC + s.loWoBoffset;
|
|
222
|
+
}
|
|
223
|
+
return out * 100;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** APCA Lc for a text fg over a bg, both sRGB [r,g,b]. Signed (polarity-aware). */
|
|
227
|
+
export function apcaContrast(fg, bg) {
|
|
228
|
+
return apcaContrastY(apcaY(fg), apcaY(bg));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Minimum |Lc| a text pair must clear. If size (px) / weight are given, use a
|
|
233
|
+
* conservative font-aware tier derived from the APCA readability guidance ("Bronze"
|
|
234
|
+
* use-case levels: Lc 90 ideal body, 75 min body column, 60 large/content, 45
|
|
235
|
+
* large+bold/headline, 30 spot). Otherwise fall back to the baseline floor by kind.
|
|
236
|
+
* Cited: APCA "Font Size & Weight" guidance, https://git.myndex.com/ (use-cases).
|
|
237
|
+
* This is intentionally conservative (rounds UP a requirement, never down); a
|
|
238
|
+
* consumer can override exactly via a per-pairing `minLc`.
|
|
239
|
+
*/
|
|
240
|
+
export function apcaMinLc({ kind, sizePx, weight, baseText = 60, baseLarge = 45 }) {
|
|
241
|
+
if (sizePx != null) {
|
|
242
|
+
const w = weight ?? 400;
|
|
243
|
+
if (sizePx >= 36 || (sizePx >= 24 && w >= 700)) return baseLarge; // big display
|
|
244
|
+
if (sizePx >= 24 || (sizePx >= 18.5 && w >= 600)) return baseText; // large text
|
|
245
|
+
if (sizePx >= 18) return 75; // body
|
|
246
|
+
if (sizePx >= 16) return 90; // small body
|
|
247
|
+
return 100; // < 16px: max
|
|
248
|
+
}
|
|
249
|
+
return kind === "large-text" ? baseLarge : baseText;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
// 4. Colour-vision-deficiency simulation — Machado 2009, severity 1.0 (dichromacy).
|
|
254
|
+
// Ref: G. M. Machado, M. M. Oliveira, L. A. F. Fernandes (2009), "A
|
|
255
|
+
// Physiologically-based Model for Simulation of Color Vision Deficiency",
|
|
256
|
+
// IEEE Trans. Vis. Comput. Graph. 15(6). Matrices operate on LINEAR-light sRGB.
|
|
257
|
+
// http://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html
|
|
258
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export const CVD_MATRICES = {
|
|
261
|
+
// protanopia (severity 1.0)
|
|
262
|
+
protanopia: [
|
|
263
|
+
[0.152286, 1.052583, -0.204868],
|
|
264
|
+
[0.114503, 0.786281, 0.099216],
|
|
265
|
+
[-0.003882, -0.048116, 1.051998],
|
|
266
|
+
],
|
|
267
|
+
// deuteranopia (severity 1.0)
|
|
268
|
+
deuteranopia: [
|
|
269
|
+
[0.367322, 0.860646, -0.227968],
|
|
270
|
+
[0.280085, 0.672501, 0.047413],
|
|
271
|
+
[-0.011820, 0.042940, 0.968881],
|
|
272
|
+
],
|
|
273
|
+
// tritanopia (severity 1.0)
|
|
274
|
+
tritanopia: [
|
|
275
|
+
[1.255528, -0.076749, -0.178779],
|
|
276
|
+
[-0.078411, 0.930809, 0.147602],
|
|
277
|
+
[0.004733, 0.691367, 0.303900],
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const CVD_TYPES = Object.keys(CVD_MATRICES);
|
|
282
|
+
|
|
283
|
+
/** Simulate how an sRGB [r,g,b] colour appears to someone with the given CVD type. */
|
|
284
|
+
export function simulateCVD(rgb, type) {
|
|
285
|
+
const M = CVD_MATRICES[type];
|
|
286
|
+
if (!M) throw new Error(`unknown CVD type: ${type}`);
|
|
287
|
+
const lin = rgb.map(srgbToLinear); // Machado matrices act on linear light
|
|
288
|
+
const out = M.map((row) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]);
|
|
289
|
+
return out.map((c) => Math.round(linearToSrgb(Math.max(0, Math.min(1, c)))));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
// 5. Threshold model + per-pair evaluation (pure)
|
|
294
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export const DEFAULT_THRESHOLDS = {
|
|
297
|
+
minRatioText: 4.5, // WCAG 2.2 SC 1.4.3 (AA, normal text)
|
|
298
|
+
minRatioLarge: 3.0, // WCAG 2.2 SC 1.4.3 (AA, large text)
|
|
299
|
+
minRatioUi: 3.0, // WCAG 2.2 SC 1.4.11 (non-text contrast)
|
|
300
|
+
minLcText: 60, // APCA baseline floor, body
|
|
301
|
+
minLcLarge: 45, // APCA baseline floor, large
|
|
302
|
+
collapseDeltaE: 10, // categorical CIEDE2000 collapse floor
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** The WCAG-AA ratio floor for a pairing kind. */
|
|
306
|
+
export function ratioFloor(kind, t) {
|
|
307
|
+
if (kind === "ui") return t.minRatioUi;
|
|
308
|
+
if (kind === "large-text") return t.minRatioLarge;
|
|
309
|
+
return t.minRatioText; // text (default)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Evaluate ONE resolved pairing (fg/bg already hex) against all three checks.
|
|
314
|
+
* Pure: (pairing, thresholds) → per-pair report. `kind` drives which checks apply:
|
|
315
|
+
* text / large-text → WCAG AA ratio, APCA Lc, and CVD-safe AA under every CVD.
|
|
316
|
+
* ui → WCAG 1.4.11 (≥3:1), and CVD-safe ≥3:1 under every CVD.
|
|
317
|
+
*/
|
|
318
|
+
export function evaluatePair(pairing, thresholds = DEFAULT_THRESHOLDS) {
|
|
319
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
320
|
+
const kind = pairing.kind || "text";
|
|
321
|
+
const fgRgb = parseHex(pairing.fgHex), bgRgb = parseHex(pairing.bgHex);
|
|
322
|
+
const floor = ratioFloor(kind, t);
|
|
323
|
+
|
|
324
|
+
const ratio = wcagContrast(fgRgb, bgRgb);
|
|
325
|
+
const wcagPass = ratio + 1e-9 >= floor;
|
|
326
|
+
|
|
327
|
+
// CVD-safe contrast: recompute the WCAG ratio with BOTH colours simulated.
|
|
328
|
+
const cvd = { normalRatio: round2(ratio) };
|
|
329
|
+
let cvdPass = true;
|
|
330
|
+
for (const type of CVD_TYPES) {
|
|
331
|
+
const r = wcagContrast(simulateCVD(fgRgb, type), simulateCVD(bgRgb, type));
|
|
332
|
+
const pass = r + 1e-9 >= floor;
|
|
333
|
+
cvd[type] = { ratio: round2(r), floor, pass };
|
|
334
|
+
if (!pass) cvdPass = false;
|
|
335
|
+
}
|
|
336
|
+
cvd.pass = cvdPass;
|
|
337
|
+
|
|
338
|
+
const checks = {};
|
|
339
|
+
const isText = kind === "text" || kind === "large-text";
|
|
340
|
+
|
|
341
|
+
checks.wcagAA = wcagPass;
|
|
342
|
+
|
|
343
|
+
let apca = null;
|
|
344
|
+
if (isText) {
|
|
345
|
+
const Lc = apcaContrast(fgRgb, bgRgb);
|
|
346
|
+
const minLc = pairing.minLc ?? apcaMinLc({
|
|
347
|
+
kind, sizePx: pairing.size, weight: pairing.weight,
|
|
348
|
+
baseText: t.minLcText, baseLarge: t.minLcLarge,
|
|
349
|
+
});
|
|
350
|
+
const apcaPass = Math.abs(Lc) + 1e-9 >= minLc;
|
|
351
|
+
apca = { Lc: round1(Lc), absLc: round1(Math.abs(Lc)), min: minLc, pass: apcaPass };
|
|
352
|
+
checks.apca = apcaPass;
|
|
353
|
+
} else {
|
|
354
|
+
checks.apca = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
checks.nonText = kind === "ui" ? wcagPass : null;
|
|
358
|
+
checks.cvdSafe = cvdPass;
|
|
359
|
+
|
|
360
|
+
const passed =
|
|
361
|
+
checks.wcagAA &&
|
|
362
|
+
(checks.apca !== false) &&
|
|
363
|
+
(checks.nonText !== false) &&
|
|
364
|
+
cvdPass;
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
name: pairing.name || `${pairing.fg} on ${pairing.bg}`,
|
|
368
|
+
fg: { ref: pairing.fg, hex: toHex(fgRgb) },
|
|
369
|
+
bg: { ref: pairing.bg, hex: toHex(bgRgb) },
|
|
370
|
+
kind,
|
|
371
|
+
size: pairing.size ?? null,
|
|
372
|
+
weight: pairing.weight ?? null,
|
|
373
|
+
wcag: { ratio: round2(ratio), min: floor, pass: wcagPass },
|
|
374
|
+
apca,
|
|
375
|
+
cvd,
|
|
376
|
+
checks,
|
|
377
|
+
passed,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Detect CATEGORICAL collapse: every unordered pair of categorical colours must
|
|
383
|
+
* stay ≥ collapseDeltaE apart (CIEDE2000) under NORMAL vision AND under every CVD
|
|
384
|
+
* transform. A pair that collapses post-transform is reported. Pure.
|
|
385
|
+
*/
|
|
386
|
+
export function evaluateCategorical(colors, thresholds = DEFAULT_THRESHOLDS) {
|
|
387
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
388
|
+
const collapses = [];
|
|
389
|
+
const list = colors.map((c) => ({ ref: c.ref, rgb: parseHex(c.hex), hex: toHex(parseHex(c.hex)) }));
|
|
390
|
+
for (let i = 0; i < list.length; i++) {
|
|
391
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
392
|
+
const A = list[i], B = list[j];
|
|
393
|
+
const conditions = { normal: ciede2000(rgbToLab(A.rgb), rgbToLab(B.rgb)) };
|
|
394
|
+
for (const type of CVD_TYPES) {
|
|
395
|
+
conditions[type] = ciede2000(rgbToLab(simulateCVD(A.rgb, type)), rgbToLab(simulateCVD(B.rgb, type)));
|
|
396
|
+
}
|
|
397
|
+
for (const [cond, dE] of Object.entries(conditions)) {
|
|
398
|
+
if (dE + 1e-9 < t.collapseDeltaE) {
|
|
399
|
+
collapses.push({
|
|
400
|
+
a: A.ref, b: B.ref, aHex: A.hex, bHex: B.hex,
|
|
401
|
+
condition: cond, deltaE: round2(dE), min: t.collapseDeltaE,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { threshold: t.collapseDeltaE, count: collapses.length, collapses };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Whole-palette evaluation: pairs + categorical collapse → fail-closed report. Pure. */
|
|
411
|
+
export function evaluatePalette({ pairings = [], categorical = [], thresholds = DEFAULT_THRESHOLDS } = {}) {
|
|
412
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
413
|
+
const pairs = pairings.map((p) => evaluatePair(p, t));
|
|
414
|
+
const cat = evaluateCategorical(categorical, t);
|
|
415
|
+
|
|
416
|
+
const summary = {
|
|
417
|
+
pairs: pairs.length,
|
|
418
|
+
failingPairs: pairs.filter((p) => !p.passed).length,
|
|
419
|
+
wcagFailures: pairs.filter((p) => !p.checks.wcagAA).length,
|
|
420
|
+
cvdFailures: pairs.filter((p) => !p.cvd.pass).length,
|
|
421
|
+
apcaFailures: pairs.filter((p) => p.checks.apca === false).length,
|
|
422
|
+
nonTextFailures: pairs.filter((p) => p.checks.nonText === false).length,
|
|
423
|
+
categoricalCollapses: cat.count,
|
|
424
|
+
};
|
|
425
|
+
const passed = summary.failingPairs === 0 && cat.count === 0;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
passed,
|
|
429
|
+
thresholds: t,
|
|
430
|
+
summary,
|
|
431
|
+
pairs,
|
|
432
|
+
categorical: cat,
|
|
433
|
+
// Envelope a future lone `palette.cvd-safe` / `palette.apca` criterion can consume.
|
|
434
|
+
palette: {
|
|
435
|
+
cvdSafe: summary.cvdFailures === 0 && cat.count === 0,
|
|
436
|
+
apcaBaseline: summary.apcaFailures === 0,
|
|
437
|
+
nonTextContrast: summary.nonTextFailures === 0,
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
443
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
444
|
+
|
|
445
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
446
|
+
// 6. Token / pairing loading (impure; deterministic, no network/browser)
|
|
447
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/** Flatten a DTCG-ish tokens.json (primitive → semantic aliases) to { dotted.name → #hex }. */
|
|
450
|
+
export function tokensFromDTCG(json) {
|
|
451
|
+
const flat = {}; // dotted path → raw $value (may be an alias or hex)
|
|
452
|
+
const walk = (node, path) => {
|
|
453
|
+
if (node && typeof node === "object") {
|
|
454
|
+
if (typeof node.$value === "string" && (node.$type === "color" || /^#|^\{/.test(node.$value))) {
|
|
455
|
+
flat[path.join(".")] = node.$value;
|
|
456
|
+
}
|
|
457
|
+
for (const [k, v] of Object.entries(node)) {
|
|
458
|
+
if (k.startsWith("$")) continue;
|
|
459
|
+
walk(v, [...path, k]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
walk(json, []);
|
|
464
|
+
const resolve1 = (v, seen = new Set()) => {
|
|
465
|
+
const m = /^\{(.+)\}$/.exec(String(v).trim());
|
|
466
|
+
if (!m) return v;
|
|
467
|
+
if (seen.has(m[1])) throw new Error(`alias cycle at {${m[1]}}`);
|
|
468
|
+
if (!(m[1] in flat)) throw new Error(`unresolved alias {${m[1]}}`);
|
|
469
|
+
return resolve1(flat[m[1]], new Set([...seen, m[1]]));
|
|
470
|
+
};
|
|
471
|
+
const out = {};
|
|
472
|
+
for (const [k, v] of Object.entries(flat)) {
|
|
473
|
+
let resolved;
|
|
474
|
+
try { resolved = resolve1(v); } catch { continue; }
|
|
475
|
+
if (/^#?[0-9a-fA-F]{3}$|^#?[0-9a-fA-F]{6}$/.test(String(resolved).replace(/^#/, "#"))) {
|
|
476
|
+
out[k] = String(resolved).startsWith("#") ? resolved : "#" + resolved;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Parse a tokens.css for `--name: #hex;` custom properties → { name(no --) → #hex }. */
|
|
483
|
+
export function tokensFromCSS(css) {
|
|
484
|
+
const out = {};
|
|
485
|
+
const re = /--([a-zA-Z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g;
|
|
486
|
+
let m;
|
|
487
|
+
while ((m = re.exec(css))) out[m[1]] = m[2];
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Load a token map from a .json (DTCG) or .css path. */
|
|
492
|
+
export async function loadTokens(path) {
|
|
493
|
+
const raw = await readFile(path, "utf8");
|
|
494
|
+
if (path.endsWith(".json")) return tokensFromDTCG(JSON.parse(raw));
|
|
495
|
+
if (path.endsWith(".css")) return tokensFromCSS(raw);
|
|
496
|
+
// Best-effort: try JSON, else CSS.
|
|
497
|
+
try { return tokensFromDTCG(JSON.parse(raw)); } catch { return tokensFromCSS(raw); }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Resolve a colour REFERENCE (a token name or a literal #hex) against the map.
|
|
502
|
+
* Tolerant of the brand's short names: tries the exact key, then common prefixes
|
|
503
|
+
* (`bs-color-`, `bs-grade-`, `color.`, `bs-`), then a case-insensitive match.
|
|
504
|
+
*/
|
|
505
|
+
export function resolveColor(map, ref) {
|
|
506
|
+
const r = String(ref).trim();
|
|
507
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(r)) return r;
|
|
508
|
+
if (r in map) return map[r];
|
|
509
|
+
for (const p of ["bs-color-", "bs-grade-", "color.", "primitive.", "bs-"]) {
|
|
510
|
+
if (p + r in map) return map[p + r];
|
|
511
|
+
}
|
|
512
|
+
const lc = r.toLowerCase();
|
|
513
|
+
for (const [k, v] of Object.entries(map)) {
|
|
514
|
+
if (k.toLowerCase() === lc || k.toLowerCase().endsWith("-" + lc) || k.toLowerCase().endsWith("." + lc)) return v;
|
|
515
|
+
}
|
|
516
|
+
throw new Error(`unresolved colour reference: "${ref}"`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Resolve a pairings.json spec (token names → hex) against a token map. */
|
|
520
|
+
export function resolvePairings(spec, map) {
|
|
521
|
+
const pairings = (spec.pairings || []).map((p) => ({
|
|
522
|
+
...p,
|
|
523
|
+
fgHex: resolveColor(map, p.fg),
|
|
524
|
+
bgHex: resolveColor(map, p.bg),
|
|
525
|
+
}));
|
|
526
|
+
const categorical = (spec.categorical || []).map((ref) => ({ ref, hex: resolveColor(map, ref) }));
|
|
527
|
+
return { pairings, categorical, thresholds: spec.thresholds || {} };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Full run: load tokens + pairings → resolve → evaluate. Exposed for tests. */
|
|
531
|
+
export async function runPaletteGate({ tokens, pairings, thresholds = {} }) {
|
|
532
|
+
const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
|
|
533
|
+
const spec = typeof pairings === "string" ? JSON.parse(await readFile(pairings, "utf8")) : pairings;
|
|
534
|
+
const resolved = resolvePairings(spec, map);
|
|
535
|
+
return evaluatePalette({
|
|
536
|
+
pairings: resolved.pairings,
|
|
537
|
+
categorical: resolved.categorical,
|
|
538
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...resolved.thresholds, ...thresholds },
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
543
|
+
// 7. CLI
|
|
544
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
function envThresholds() {
|
|
547
|
+
const t = {};
|
|
548
|
+
const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
|
|
549
|
+
const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
|
|
550
|
+
set("minRatioText", "PALETTE_MIN_RATIO_TEXT");
|
|
551
|
+
set("minRatioLarge", "PALETTE_MIN_RATIO_LARGE");
|
|
552
|
+
set("minRatioUi", "PALETTE_MIN_RATIO_UI");
|
|
553
|
+
set("minLcText", "PALETTE_MIN_LC_TEXT");
|
|
554
|
+
set("minLcLarge", "PALETTE_MIN_LC_LARGE");
|
|
555
|
+
set("collapseDeltaE", "PALETTE_COLLAPSE_DELTAE");
|
|
556
|
+
return t;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function main() {
|
|
560
|
+
const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
561
|
+
const tokens = argv[0] || process.env.PALETTE_TOKENS;
|
|
562
|
+
const pairings = argv[1] || process.env.PALETTE_PAIRINGS;
|
|
563
|
+
if (!tokens || !pairings) {
|
|
564
|
+
console.error("✗ palette-gate: usage: palette-gate.mjs <tokens.(json|css)> <pairings.json>");
|
|
565
|
+
console.error(" (or set $PALETTE_TOKENS and $PALETTE_PAIRINGS)");
|
|
566
|
+
process.exit(2);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const report = await runPaletteGate({ tokens, pairings, thresholds: envThresholds() });
|
|
570
|
+
if (process.env.PALETTE_REPORT) {
|
|
571
|
+
await writeFile(resolve(process.env.PALETTE_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const s = report.summary;
|
|
575
|
+
const line =
|
|
576
|
+
`palette-gate: ${s.pairs} pair(s) — ${s.failingPairs} failing ` +
|
|
577
|
+
`(WCAG ${s.wcagFailures}, CVD ${s.cvdFailures}, APCA ${s.apcaFailures}, non-text ${s.nonTextFailures}) · ` +
|
|
578
|
+
`${s.categoricalCollapses} categorical collapse(s)`;
|
|
579
|
+
|
|
580
|
+
const detail = (p) => {
|
|
581
|
+
const bits = [`WCAG ${p.wcag.ratio}:1 (min ${p.wcag.min})`];
|
|
582
|
+
if (p.apca) bits.push(`APCA |Lc| ${p.apca.absLc} (min ${p.apca.min})`);
|
|
583
|
+
const cvdBad = CVD_TYPES.filter((c) => !p.cvd[c].pass).map((c) => `${c} ${p.cvd[c].ratio}:1`);
|
|
584
|
+
if (cvdBad.length) bits.push(`CVD-fail: ${cvdBad.join(", ")}`);
|
|
585
|
+
return ` · ${p.name} [${p.kind}] ${p.fg.hex}/${p.bg.hex} — ${bits.join("; ")}`;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
if (!report.passed) {
|
|
589
|
+
console.error(`✗ ${line}`);
|
|
590
|
+
for (const p of report.pairs) if (!p.passed) console.error(detail(p));
|
|
591
|
+
for (const c of report.categorical.collapses) {
|
|
592
|
+
console.error(` · collapse: ${c.a} vs ${c.b} (${c.aHex}/${c.bHex}) under ${c.condition} — ΔE ${c.deltaE} < ${c.min}`);
|
|
593
|
+
}
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
console.log(`✓ ${line}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
600
|
+
main().catch((e) => { console.error("✗ palette-gate: error —", e.stack || e.message); process.exit(1); });
|
|
601
|
+
}
|