@bounded-systems/conformance-kit 0.5.0 → 0.6.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.
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // Opacity-contrast gate — the CROSS-CUTTING guard of the Token Accessibility suite,
3
+ // and the one that catches a whole class of real bugs (the bounded.tools opacity
4
+ // regression): a colour token that clears AA at full strength but is APPLIED at a
5
+ // reduced opacity — `opacity: .6`, `color-mix(… N%, transparent)`, an `--alpha`
6
+ // token, an 8-digit `#rrggbbaa` — so the EFFECTIVE, composited colour silently
7
+ // drops below contrast. Per-pair palette checks miss it because they test the
8
+ // opaque token; per-page axe can miss it when the element isn't sampled.
9
+ //
10
+ // For every consumer-declared "opacity applied to a foreground" usage we composite
11
+ // the foreground OVER its background at the stated alpha (Porter-Duff source-over,
12
+ // opaque backdrop) and require the EFFECTIVE WCAG contrast to clear the floor:
13
+ // · text ≥ 4.5:1 (WCAG 2.2 SC 1.4.3 AA)
14
+ // · large-text ≥ 3:1 (SC 1.4.3 AA, large)
15
+ // · ui ≥ 3:1 (SC 1.4.11 non-text)
16
+ // We report BOTH the nominal (alpha = 1) ratio and the effective ratio, so the DROP
17
+ // the opacity introduces is visible. A combo that drops below its floor → FAIL.
18
+ //
19
+ // HONEST SCOPE: this assumes the backdrop the consumer declares is the actual one
20
+ // (the real backdrop is a DOM/stacking-context fact). If a translucent layer sits
21
+ // over an unknown/photo background, contrast can't be guaranteed statically — the
22
+ // gate flags such usages as `unknownBackdrop` for manual review rather than passing
23
+ // them. Stacked translucent layers can be expressed by pre-compositing.
24
+ //
25
+ // Zero-dependency: colour-science primitives are imported from the palette gate.
26
+ //
27
+ // node gates/opacity-contrast-gate.mjs <tokens.(json|css)> <usages.json>
28
+ //
29
+ // INPUTS:
30
+ // argv[2] / $OPACITY_TOKENS the token map (DTCG json | tokens.css), same loader
31
+ // as the palette gate.
32
+ // argv[3] / $OPACITY_USAGES a `usages.json` the consumer authors:
33
+ // { "thresholds": { … }, "opacityTokens": { "muted": 0.6, … },
34
+ // "usages": [ { "fg":"token|#hex", "bg":"token|#hex",
35
+ // "opacity": 0.6 | "{muted}", // 0..1, or a ref into opacityTokens
36
+ // "kind": "text"|"large-text"|"ui", "name?":"…",
37
+ // "unknownBackdrop?": true } ] }
38
+ //
39
+ // Thresholds (config ⊕ env), fail closed:
40
+ // $OPACITY_MIN_RATIO_TEXT (default 4.5) SC 1.4.3 (AA, text)
41
+ // $OPACITY_MIN_RATIO_LARGE (default 3.0) SC 1.4.3 (AA, large)
42
+ // $OPACITY_MIN_RATIO_UI (default 3.0) SC 1.4.11 (non-text)
43
+ import { readFile, writeFile } from "node:fs/promises";
44
+ import { resolve } from "node:path";
45
+ import { parseHex, toHex, wcagContrast, loadTokens, resolveColor } from "./palette-gate.mjs";
46
+
47
+ export const DEFAULT_THRESHOLDS = {
48
+ minRatioText: 4.5, // SC 1.4.3
49
+ minRatioLarge: 3.0, // SC 1.4.3 large
50
+ minRatioUi: 3.0, // SC 1.4.11
51
+ };
52
+
53
+ const round2 = (n) => Math.round(n * 100) / 100;
54
+
55
+ /**
56
+ * Composite a foreground sRGB over a background sRGB at alpha (0..1).
57
+ * Porter-Duff "source-over" with an OPAQUE backdrop: out = fg·α + bg·(1−α),
58
+ * computed per channel in sRGB space. (sRGB compositing is what a browser's
59
+ * `opacity` / `rgba()` / `color-mix(… transparent)` effectively does for a single
60
+ * translucent layer over an opaque backdrop.)
61
+ * Ref: Porter & Duff (1984), "Compositing Digital Images"; CSS Color 4 alpha.
62
+ */
63
+ export function compositeOver(fgRgb, bgRgb, alpha) {
64
+ const a = Math.max(0, Math.min(1, alpha));
65
+ return [0, 1, 2].map((i) => fgRgb[i] * a + bgRgb[i] * (1 - a));
66
+ }
67
+
68
+ export function ratioFloor(kind, t) {
69
+ if (kind === "ui") return t.minRatioUi;
70
+ if (kind === "large-text") return t.minRatioLarge;
71
+ return t.minRatioText;
72
+ }
73
+
74
+ /** Evaluate ONE opacity-on-foreground usage. `fgHex`/`bgHex` resolved. Pure. */
75
+ export function evaluateUsage(usage, thresholds = DEFAULT_THRESHOLDS) {
76
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
77
+ const kind = usage.kind || "text";
78
+ const fg = parseHex(usage.fgHex);
79
+ const bg = parseHex(usage.bgHex);
80
+ const alpha = usage.opacity == null ? 1 : Number(usage.opacity);
81
+ const floor = ratioFloor(kind, t);
82
+
83
+ const nominal = wcagContrast(fg, bg);
84
+ const composited = compositeOver(fg, bg, alpha);
85
+ const effective = wcagContrast(composited, bg);
86
+ const pass = effective + 1e-9 >= floor;
87
+
88
+ const findings = [];
89
+ if (usage.unknownBackdrop) {
90
+ findings.push({ severity: "error", sc: kind === "ui" ? "1.4.11" : "1.4.3", msg: `translucent fg over an UNKNOWN backdrop — effective contrast cannot be guaranteed statically (declared backdrop ${toHex(bg)} assumed)` });
91
+ } else if (!pass) {
92
+ findings.push({ severity: "error", sc: kind === "ui" ? "1.4.11" : "1.4.3", msg: `effective contrast ${round2(effective)}:1 < ${floor}:1 at opacity ${alpha} (nominal ${round2(nominal)}:1)` });
93
+ }
94
+
95
+ return {
96
+ name: usage.name || `${usage.fg}@${alpha} on ${usage.bg}`,
97
+ fg: { ref: usage.fg, hex: toHex(fg) },
98
+ bg: { ref: usage.bg, hex: toHex(bg) },
99
+ kind,
100
+ opacity: alpha,
101
+ effectiveHex: toHex(composited),
102
+ nominalRatio: round2(nominal),
103
+ effectiveRatio: round2(effective),
104
+ floor,
105
+ drop: round2(nominal - effective),
106
+ unknownBackdrop: !!usage.unknownBackdrop,
107
+ findings,
108
+ passed: findings.length === 0,
109
+ };
110
+ }
111
+
112
+ /** Whole-suite evaluation → fail-closed report. Pure. */
113
+ export function evaluateOpacityContrast({ usages = [], thresholds = DEFAULT_THRESHOLDS } = {}) {
114
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
115
+ const results = usages.map((u) => evaluateUsage(u, t));
116
+ const failing = results.filter((r) => !r.passed);
117
+ return {
118
+ passed: failing.length === 0,
119
+ thresholds: t,
120
+ summary: {
121
+ usages: results.length,
122
+ failing: failing.length,
123
+ unknownBackdrops: results.filter((r) => r.unknownBackdrop).length,
124
+ worstDrop: results.reduce((m, r) => Math.max(m, r.drop), 0),
125
+ },
126
+ usages: results,
127
+ // Envelope a future lone `opacity.effective-contrast` criterion can consume.
128
+ opacity: { effectiveContrast: failing.length === 0 },
129
+ };
130
+ }
131
+
132
+ /** Resolve an opacity value: a number 0..1, or `{name}` into opacityTokens. */
133
+ export function resolveOpacity(ref, opacityTokens = {}) {
134
+ if (ref == null) return 1;
135
+ if (typeof ref === "number") return ref;
136
+ const m = /^\{(.+)\}$/.exec(String(ref).trim());
137
+ if (m) return Number(opacityTokens[m[1]] ?? 1);
138
+ return Number(ref);
139
+ }
140
+
141
+ /** Full run: load tokens + usages → resolve → evaluate. Exposed for tests. */
142
+ export async function runOpacityContrastGate({ tokens, usages, thresholds = {} }) {
143
+ const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
144
+ const spec = typeof usages === "string" ? JSON.parse(await readFile(usages, "utf8")) : usages;
145
+ const resolved = (spec.usages || []).map((u) => ({
146
+ ...u,
147
+ fgHex: resolveColor(map, u.fg),
148
+ bgHex: resolveColor(map, u.bg),
149
+ opacity: resolveOpacity(u.opacity, spec.opacityTokens || {}),
150
+ }));
151
+ return evaluateOpacityContrast({
152
+ usages: resolved,
153
+ thresholds: { ...DEFAULT_THRESHOLDS, ...(spec.thresholds || {}), ...thresholds },
154
+ });
155
+ }
156
+
157
+ function envThresholds() {
158
+ const t = {};
159
+ const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
160
+ const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
161
+ set("minRatioText", "OPACITY_MIN_RATIO_TEXT");
162
+ set("minRatioLarge", "OPACITY_MIN_RATIO_LARGE");
163
+ set("minRatioUi", "OPACITY_MIN_RATIO_UI");
164
+ return t;
165
+ }
166
+
167
+ async function main() {
168
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
169
+ const tokens = argv[0] || process.env.OPACITY_TOKENS;
170
+ const usages = argv[1] || process.env.OPACITY_USAGES;
171
+ if (!tokens || !usages) {
172
+ console.error("✗ opacity-contrast-gate: usage: opacity-contrast-gate.mjs <tokens.(json|css)> <usages.json>");
173
+ console.error(" (or set $OPACITY_TOKENS and $OPACITY_USAGES)");
174
+ process.exit(2);
175
+ }
176
+ const report = await runOpacityContrastGate({ tokens, usages, thresholds: envThresholds() });
177
+ if (process.env.OPACITY_REPORT) await writeFile(resolve(process.env.OPACITY_REPORT), JSON.stringify(report, null, 2) + "\n");
178
+
179
+ const s = report.summary;
180
+ const line = `opacity-contrast-gate: ${s.usages} usage(s) — ${s.failing} failing (worst drop ${s.worstDrop}:1)`;
181
+ if (!report.passed) {
182
+ console.error(`✗ ${line}`);
183
+ for (const u of report.usages) for (const f of u.findings) console.error(` · ${u.name} [${u.kind}] ${u.fg.hex}@${u.opacity}→${u.effectiveHex}/${u.bg.hex}: ${f.msg}`);
184
+ process.exit(1);
185
+ }
186
+ console.log(`✓ ${line}`);
187
+ }
188
+
189
+ if (import.meta.url === `file://${process.argv[1]}`) {
190
+ main().catch((e) => { console.error("✗ opacity-contrast-gate: error —", e.stack || e.message); process.exit(1); });
191
+ }
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // Pairing extractor — the COVERAGE engine of the Token Accessibility suite. The
3
+ // contrast / CVD / APCA checks in the palette gate are only as complete as their
4
+ // pairings list, and a HAND-MAINTAINED pairings.json misses combos — that's exactly
5
+ // how the bounded.tools opacity-contrast regression slipped through. This tool
6
+ // DERIVES the real foreground×background pairings from ACTUAL stylesheet usage, so
7
+ // coverage is complete without hand-declaring every pair, then feeds them to the
8
+ // palette gate and emits a human-readable PAIRING MATRIX (every fg×bg combo actually
9
+ // used → WCAG ratio · APCA Lc · per-CVD ratios) so a reviewer can SEE what co-occurs
10
+ // and that it's all clean.
11
+ //
12
+ // HOW (zero-dep, bounded, documented heuristic — no DOM, so no full cascade):
13
+ // 1. Parse the stylesheet(s) into rules: selector-list → { color, background,
14
+ // border-color, outline-color, fill, stroke }, resolving `var(--token)` and
15
+ // literal colours against the token map.
16
+ // 2. A rule that sets BOTH a foreground (color/fill/stroke/border) and a
17
+ // background → a DEFINITE co-occurrence (confidence "rule").
18
+ // 3. A rule that sets only a foreground is paired with a background by STRUCTURAL
19
+ // containment: the nearest ancestor selector (by selector-string prefix, e.g.
20
+ // `.card` is an ancestor of `.card .title`) that declares a background; else
21
+ // the ROOT surface (`:root`/`html`/`body` background) (confidence "surface").
22
+ // 4. `border-color`/`outline-color` foregrounds are `kind:"ui"`; everything else
23
+ // defaults to `kind:"text"` (a consumer override map can re-tag).
24
+ // 5. Dedup by (fgHex,bgHex,kind). DECLARED pairings (an optional supplement) are
25
+ // UNIONED in, so you get extracted ∪ declared.
26
+ //
27
+ // HONEST SCOPE: containment-by-selector-prefix is a heuristic, not a real cascade —
28
+ // it can pair a fg with a backdrop it never actually renders on (false positive,
29
+ // SAFE: an extra clean pair) or, for deeply dynamic DOMs, miss a backdrop (covered
30
+ // by also pairing against the root surface). Treat the matrix as "every combo the
31
+ // stylesheet PLAUSIBLY puts together", reviewed by a human — superset coverage beats
32
+ // a hand-list that silently omits the dangerous pair.
33
+ //
34
+ // Zero-dependency; colour-science + evaluation imported from the palette gate.
35
+ //
36
+ // node gates/pairing-extractor.mjs <tokens.(json|css)> <style1.css> [style2.css …]
37
+ //
38
+ // INPUTS / ENV:
39
+ // $PAIRING_DECLARED optional pairings.json to UNION in (declared ∪ extracted).
40
+ // $PAIRING_MATRIX write the Markdown matrix here (else stdout).
41
+ // $PAIRING_REPORT write the full JSON report here.
42
+ // $PAIRING_GATE "1" → also run the palette gate over the union and exit 1 on
43
+ // any failing pair (default: report-only, exit 0).
44
+ import { readFile, writeFile } from "node:fs/promises";
45
+ import { resolve } from "node:path";
46
+ import { tokensFromCSS, loadTokens, resolveColor, evaluatePair, evaluatePalette, CVD_TYPES, parseHex, toHex } from "./palette-gate.mjs";
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // 1. Minimal CSS rule parser (zero-dep) — selectors → colour-bearing declarations
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ const COLOR_PROPS = {
53
+ color: "fg", fill: "fg", stroke: "fg",
54
+ "border-color": "ui", "border-top-color": "ui", "border-bottom-color": "ui",
55
+ "border-left-color": "ui", "border-right-color": "ui", "outline-color": "ui",
56
+ "background-color": "bg", background: "bg",
57
+ };
58
+
59
+ /** Strip comments + at-rule prelude noise, then yield { selectors:[], decls:{} } rules. */
60
+ export function parseRules(css) {
61
+ const clean = css.replace(/\/\*[\s\S]*?\*\//g, "");
62
+ const rules = [];
63
+ // Flatten one level of @media/@supports by stripping the at-rule wrapper braces.
64
+ const body = clean.replace(/@(media|supports|layer)[^{]*\{/g, "");
65
+ const re = /([^{}]+)\{([^{}]*)\}/g;
66
+ let m;
67
+ while ((m = re.exec(body))) {
68
+ const selectors = m[1].split(",").map((s) => s.trim()).filter(Boolean);
69
+ if (!selectors.length || selectors[0].startsWith("@")) continue;
70
+ const decls = {};
71
+ for (const d of m[2].split(";")) {
72
+ const i = d.indexOf(":");
73
+ if (i < 0) continue;
74
+ decls[d.slice(0, i).trim().toLowerCase()] = d.slice(i + 1).trim();
75
+ }
76
+ rules.push({ selectors, decls });
77
+ }
78
+ return rules;
79
+ }
80
+
81
+ /** Resolve a CSS colour VALUE (`var(--t)`, `#hex`, or a token name) to #hex, or null. */
82
+ export function resolveCssColor(value, map) {
83
+ if (value == null) return null;
84
+ let v = String(value).trim();
85
+ const varm = /var\(\s*--([a-zA-Z0-9-]+)\s*(?:,[^)]*)?\)/.exec(v);
86
+ if (varm) { try { return resolveColor(map, varm[1]); } catch { return null; } }
87
+ const hex = /#[0-9a-fA-F]{3,8}/.exec(v);
88
+ if (hex) { try { return resolveColor(map, hex[0].slice(0, 7)); } catch { return hex[0].slice(0, 7); } }
89
+ // bare token name
90
+ try { return resolveColor(map, v.split(/\s+/)[0]); } catch { return null; }
91
+ }
92
+
93
+ /** Find the token NAME whose value equals a hex (for readable matrix labels). */
94
+ function nameForHex(map, hex) {
95
+ const h = hex.toLowerCase();
96
+ for (const [k, v] of Object.entries(map)) { try { if (toHex(parseHex(v)).toLowerCase() === h) return k; } catch {} }
97
+ return hex;
98
+ }
99
+
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ // 2. Extraction — rules → fg/bg pairings (pure)
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+
104
+ const ROOT_SELECTORS = new Set([":root", "html", "body", "*", ":where(html)"]);
105
+
106
+ /** Is `anc` a structural ancestor of `sel` (prefix-of-compound heuristic)? */
107
+ function isAncestorSelector(anc, sel) {
108
+ if (anc === sel) return false;
109
+ // descendant combinator: ".card" ancestor of ".card .title", ".card>.title", ".card.x"
110
+ return sel.startsWith(anc + " ") || sel.startsWith(anc + ">") || sel.startsWith(anc + " >") || sel.startsWith(anc + ":");
111
+ }
112
+
113
+ /**
114
+ * Derive pairings from parsed rules + token map. Returns
115
+ * { pairings:[{fg,bg,fgHex,bgHex,kind,source,confidence}], surfaces, foregrounds }.
116
+ * Pure.
117
+ */
118
+ export function extractPairings(rules, map, opts = {}) {
119
+ // Per simple-selector record fg(s) and bg.
120
+ const flat = [];
121
+ for (const r of rules) {
122
+ let bg = null;
123
+ const fgs = [];
124
+ for (const [prop, role] of Object.entries(COLOR_PROPS)) {
125
+ if (!(prop in r.decls)) continue;
126
+ const hex = resolveCssColor(r.decls[prop], map);
127
+ if (!hex) continue;
128
+ if (role === "bg") bg = hex;
129
+ else fgs.push({ hex, kind: role === "ui" ? "ui" : "text", prop });
130
+ }
131
+ if (bg == null && fgs.length === 0) continue;
132
+ for (const sel of r.selectors) flat.push({ sel, bg, fgs });
133
+ }
134
+
135
+ // Root surface background(s).
136
+ const rootBgs = flat.filter((f) => f.bg && ROOT_SELECTORS.has(f.sel.split(/[ >:]/)[0]) ).map((f) => f.bg);
137
+ const defaultSurface = rootBgs[0] || opts.defaultBackground || null;
138
+
139
+ // Background-declaring selectors (surfaces) for containment lookup.
140
+ const surfaces = flat.filter((f) => f.bg).map((f) => ({ sel: f.sel, bg: f.bg }));
141
+
142
+ const pairings = [];
143
+ const seen = new Set();
144
+ const emit = (fgHex, bgHex, kind, source, confidence) => {
145
+ if (!fgHex || !bgHex || fgHex.toLowerCase() === bgHex.toLowerCase()) return;
146
+ const key = `${fgHex.toLowerCase()}|${bgHex.toLowerCase()}|${kind}`;
147
+ if (seen.has(key)) return;
148
+ seen.add(key);
149
+ pairings.push({
150
+ fg: nameForHex(map, fgHex), bg: nameForHex(map, bgHex),
151
+ fgHex, bgHex, kind, source, confidence,
152
+ });
153
+ };
154
+
155
+ for (const f of flat) {
156
+ for (const fg of f.fgs) {
157
+ if (f.bg) { emit(fg.hex, f.bg, fg.kind, f.sel, "rule"); continue; }
158
+ // containment: nearest ancestor surface
159
+ const anc = surfaces.filter((s) => isAncestorSelector(s.sel, f.sel));
160
+ if (anc.length) for (const a of anc) emit(fg.hex, a.bg, fg.kind, `${f.sel} ⊂ ${a.sel}`, "surface");
161
+ else if (defaultSurface) emit(fg.hex, defaultSurface, fg.kind, `${f.sel} ⊂ :root`, "root");
162
+ }
163
+ }
164
+ return { pairings, surfaces, defaultSurface };
165
+ }
166
+
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+ // 3. Matrix — evaluate every extracted pair, render Markdown (pure)
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+
171
+ /** Evaluate each pairing through the palette gate's per-pair check → matrix rows. */
172
+ export function buildMatrix(pairings) {
173
+ return pairings.map((p) => {
174
+ const ev = evaluatePair({ ...p, fgHex: p.fgHex, bgHex: p.bgHex });
175
+ return {
176
+ fg: p.fg, bg: p.bg, fgHex: p.fgHex, bgHex: p.bgHex, kind: p.kind,
177
+ confidence: p.confidence, source: p.source,
178
+ wcag: ev.wcag.ratio, wcagPass: ev.checks.wcagAA,
179
+ apca: ev.apca ? ev.apca.absLc : null,
180
+ cvd: Object.fromEntries(CVD_TYPES.map((c) => [c, ev.cvd[c].ratio])),
181
+ cvdPass: ev.cvd.pass,
182
+ passed: ev.passed,
183
+ };
184
+ });
185
+ }
186
+
187
+ /** Render the matrix as a Markdown table. Pure. */
188
+ export function renderMatrixMarkdown(matrix) {
189
+ const head = `| fg | bg | kind | WCAG | APCA Lc | ${CVD_TYPES.map((c) => c.slice(0, 4)).join(" | ")} | pass | conf |`;
190
+ const sep = `|${"---|".repeat(6 + CVD_TYPES.length)}`;
191
+ const rows = matrix.map((m) =>
192
+ `| ${m.fg} | ${m.bg} | ${m.kind} | ${m.wcag}:1 | ${m.apca ?? "—"} | ${CVD_TYPES.map((c) => m.cvd[c]).join(" | ")} | ${m.passed ? "✓" : "✗"} | ${m.confidence} |`);
193
+ return [`# Pairing matrix (${matrix.length} extracted fg×bg combos)`, "", head, sep, ...rows, ""].join("\n");
194
+ }
195
+
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ // 4. Full run
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+
200
+ /** Load tokens + stylesheets → extract ∪ declared → matrix + palette evaluation. */
201
+ export async function runPairingExtractor({ tokens, css = [], declared = null, defaultBackground = null }) {
202
+ const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
203
+ const cssTexts = await Promise.all((Array.isArray(css) ? css : [css]).map((c) => (c.includes("{") ? c : readFile(c, "utf8"))));
204
+ const rules = cssTexts.flatMap((t) => parseRules(t));
205
+ const ext = extractPairings(rules, map, { defaultBackground });
206
+
207
+ // Union declared pairings (resolved) in.
208
+ const extractedCount = ext.pairings.length;
209
+ let union = ext.pairings;
210
+ if (declared) {
211
+ const spec = typeof declared === "string" ? JSON.parse(await readFile(declared, "utf8")) : declared;
212
+ const seen = new Set(union.map((p) => `${p.fgHex.toLowerCase()}|${p.bgHex.toLowerCase()}|${p.kind}`));
213
+ for (const d of spec.pairings || []) {
214
+ try {
215
+ const fgHex = resolveColor(map, d.fg), bgHex = resolveColor(map, d.bg), kind = d.kind || "text";
216
+ const key = `${toHex(parseHex(fgHex)).toLowerCase()}|${toHex(parseHex(bgHex)).toLowerCase()}|${kind}`;
217
+ if (!seen.has(key)) { seen.add(key); union.push({ fg: d.fg, bg: d.bg, fgHex, bgHex, kind, source: "declared", confidence: "declared" }); }
218
+ } catch {}
219
+ }
220
+ }
221
+
222
+ const matrix = buildMatrix(union);
223
+ const palette = evaluatePalette({ pairings: union.map((p) => ({ ...p })) });
224
+ return {
225
+ passed: palette.passed,
226
+ summary: {
227
+ extracted: extractedCount,
228
+ declaredAdded: union.length - extractedCount,
229
+ total: union.length,
230
+ failing: matrix.filter((m) => !m.passed).length,
231
+ surfaces: ext.surfaces.length,
232
+ },
233
+ defaultSurface: ext.defaultSurface,
234
+ pairings: union,
235
+ matrix,
236
+ palette,
237
+ };
238
+ }
239
+
240
+ async function main() {
241
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
242
+ const tokens = argv[0] || process.env.PAIRING_TOKENS;
243
+ const css = argv.slice(1);
244
+ if (!tokens || css.length === 0) {
245
+ console.error("✗ pairing-extractor: usage: pairing-extractor.mjs <tokens.(json|css)> <style1.css> [style2.css …]");
246
+ process.exit(2);
247
+ }
248
+ const report = await runPairingExtractor({ tokens, css, declared: process.env.PAIRING_DECLARED || null });
249
+ if (process.env.PAIRING_REPORT) await writeFile(resolve(process.env.PAIRING_REPORT), JSON.stringify(report, null, 2) + "\n");
250
+
251
+ const md = renderMatrixMarkdown(report.matrix);
252
+ if (process.env.PAIRING_MATRIX) await writeFile(resolve(process.env.PAIRING_MATRIX), md + "\n");
253
+ else console.log(md);
254
+
255
+ const s = report.summary;
256
+ const line = `pairing-extractor: ${s.total} pair(s) (${s.extracted} extracted + ${s.declaredAdded} declared) — ${s.failing} failing`;
257
+ if (process.env.PAIRING_GATE === "1" && !report.passed) {
258
+ console.error(`✗ ${line}`);
259
+ for (const m of report.matrix) if (!m.passed) console.error(` · ${m.fg}/${m.bg} [${m.kind}] WCAG ${m.wcag}:1`);
260
+ process.exit(1);
261
+ }
262
+ console.error(`✓ ${line}`);
263
+ }
264
+
265
+ if (import.meta.url === `file://${process.argv[1]}`) {
266
+ main().catch((e) => { console.error("✗ pairing-extractor: error —", e.stack || e.message); process.exit(1); });
267
+ }
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ // Target-size gate — STATIC analysis over a site/brand's INTERACTIVE-TARGET size
3
+ // tokens (the dimensions a design system ships for buttons, controls, icon hit-
4
+ // areas, list rows, …), member of the Token Accessibility suite. Reasons about the
5
+ // size TOKENS, not a rendered layout, so it answers: "do these control tokens
6
+ // PERMIT a large-enough pointer target, or do they bake in a too-small one?"
7
+ //
8
+ // Checks, mapped to WCAG 2.2:
9
+ // 1. TARGET SIZE (Minimum) — SC 2.5.8 (AA): the bounding box of a pointer target
10
+ // must be ≥ 24×24 CSS px (unless an exception applies). A declared target token
11
+ // whose min(width,height) < 24px → ERROR (fails closed).
12
+ // 2. TARGET SIZE (Enhanced) — SC 2.5.5 (AAA): ≥ 44×44 px. Reported as STATUS only
13
+ // (pass/fail per target) — not a hard failure, since 2.5.5 is AAA.
14
+ //
15
+ // SC 2.5.8 EXCEPTIONS (spacing, inline, user-agent, essential): a consumer may mark
16
+ // a target `exception: "inline" | "essential" | "user-agent" | "spacing"` with a
17
+ // `reason`; an exempt target is recorded (not failed) but the reason is surfaced so
18
+ // the claim stays auditable. The HONEST SCOPE: tokens declare *intended* dimensions;
19
+ // only the rendered DOM proves the actual box and the spacing-offset exception — so
20
+ // this gate verifies the tokens don't UNDERCUT the floor, not that every instance
21
+ // meets it. (The axe-gate / a manual check cover the rendered side.)
22
+ //
23
+ // Zero-dependency, pure exported primitives + a fail-closed CLI.
24
+ //
25
+ // node gates/target-size-gate.mjs [config.json]
26
+ //
27
+ // INPUTS the consumer supplies (the consumer DECLARES which tokens are targets —
28
+ // nothing is auto-assumed, because "is this token a tap target?" is design intent):
29
+ // argv[2] / $TARGET_CONFIG a `config.json`:
30
+ // { "thresholds": { "minPx": 24, "aaaPx": 44 },
31
+ // "tokens": { "control-min": "44px", … }, // optional token map
32
+ // "targets": [ { "name":"icon-button", "width":"{control-min}"|"24px",
33
+ // "height":"24px", "exception?":"inline", "reason?":"…" } ] }
34
+ // `width`/`height` (or a single `size` for a square) are a literal px, a `{name}`
35
+ // ref into `tokens`, or a number. A target with only one dimension is treated as
36
+ // a square of that side.
37
+ // $TARGET_REPORT path to write the machine-readable JSON report.
38
+ //
39
+ // Thresholds (config ⊕ env), fail closed:
40
+ // $TARGET_MIN_PX (default 24) SC 2.5.8 AA floor
41
+ // $TARGET_AAA_PX (default 44) SC 2.5.5 AAA target (status only)
42
+ import { readFile, writeFile } from "node:fs/promises";
43
+ import { resolve } from "node:path";
44
+
45
+ export const DEFAULT_THRESHOLDS = {
46
+ minPx: 24, // WCAG 2.2 SC 2.5.8 (AA)
47
+ aaaPx: 44, // WCAG 2.2 SC 2.5.5 (AAA)
48
+ };
49
+
50
+ export const EXCEPTIONS = new Set(["inline", "essential", "user-agent", "spacing"]);
51
+
52
+ /** Resolve a dimension ref (`"24px"` | `24` | `"{token}"`) against a token map → px or null. */
53
+ export function resolveDimension(ref, tokens = {}) {
54
+ if (ref == null) return null;
55
+ if (typeof ref === "number") return ref;
56
+ let s = String(ref).trim();
57
+ const m = /^\{(.+)\}$/.exec(s);
58
+ if (m) { if (!(m[1] in tokens)) return null; s = String(tokens[m[1]]).trim(); }
59
+ const px = /^(-?[0-9]*\.?[0-9]+)\s*px$/.exec(s) || /^(-?[0-9]*\.?[0-9]+)$/.exec(s);
60
+ if (px) return parseFloat(px[1]);
61
+ const rem = /^(-?[0-9]*\.?[0-9]+)\s*rem$/.exec(s);
62
+ if (rem) return parseFloat(rem[1]) * 16;
63
+ return null;
64
+ }
65
+
66
+ /** Evaluate ONE declared target. Pure. */
67
+ export function evaluateTarget(target, tokens = {}, thresholds = DEFAULT_THRESHOLDS) {
68
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
69
+ const w = resolveDimension(target.width ?? target.size, tokens);
70
+ const h = resolveDimension(target.height ?? target.size, tokens);
71
+ const minSide = [w, h].filter((n) => n != null).length ? Math.min(...[w, h].filter((n) => n != null)) : null;
72
+
73
+ const exception = target.exception && EXCEPTIONS.has(target.exception) ? target.exception : null;
74
+ const aaPass = minSide == null ? null : minSide + 1e-9 >= t.minPx;
75
+ const aaaPass = minSide == null ? null : minSide + 1e-9 >= t.aaaPx;
76
+
77
+ const findings = [];
78
+ if (minSide == null) {
79
+ findings.push({ severity: "warn", sc: "2.5.8", msg: `target "${target.name}" has no resolvable dimension` });
80
+ } else if (!aaPass && !exception) {
81
+ findings.push({ severity: "error", sc: "2.5.8", msg: `${minSide}px < ${t.minPx}px (AA minimum)` });
82
+ } else if (!aaPass && exception) {
83
+ findings.push({ severity: "info", sc: "2.5.8", msg: `${minSide}px < ${t.minPx}px but exempt via "${exception}"${target.reason ? `: ${target.reason}` : ""}` });
84
+ }
85
+
86
+ return {
87
+ name: target.name,
88
+ width: w, height: h, minSide,
89
+ exception,
90
+ reason: target.reason ?? null,
91
+ aa: { min: t.minPx, pass: aaPass },
92
+ aaa: { min: t.aaaPx, pass: aaaPass },
93
+ findings,
94
+ passed: findings.every((f) => f.severity !== "error"),
95
+ };
96
+ }
97
+
98
+ /** Whole-suite evaluation → fail-closed report. Pure. */
99
+ export function evaluateTargets({ targets = [], tokens = {}, thresholds = DEFAULT_THRESHOLDS } = {}) {
100
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
101
+ const results = targets.map((tg) => evaluateTarget(tg, tokens, t));
102
+ const errors = results.flatMap((r) => r.findings.filter((f) => f.severity === "error"));
103
+ const notes = [];
104
+ if (targets.length === 0) {
105
+ notes.push("no interactive-target tokens declared — target-size cannot be asserted from tokens alone (declare the controls' size tokens to enable this gate)");
106
+ }
107
+ return {
108
+ passed: errors.length === 0,
109
+ thresholds: t,
110
+ summary: {
111
+ targets: results.length,
112
+ belowAA: results.filter((r) => r.aa.pass === false && !r.exception).length,
113
+ exempt: results.filter((r) => r.exception).length,
114
+ meetsAAA: results.filter((r) => r.aaa.pass === true).length,
115
+ unresolved: results.filter((r) => r.minSide == null).length,
116
+ },
117
+ targets: results,
118
+ notes,
119
+ coverage: targets.length === 0 ? "none" : "declared",
120
+ // Envelope a future lone `target.min-size` criterion can consume.
121
+ target: { minSizeAA: errors.length === 0, declared: targets.length },
122
+ };
123
+ }
124
+
125
+ export async function runTargetSizeGate({ config = {}, thresholds = {} }) {
126
+ const cfg = typeof config === "string" ? JSON.parse(await readFile(config, "utf8")) : config;
127
+ return evaluateTargets({
128
+ targets: cfg.targets || [],
129
+ tokens: cfg.tokens || {},
130
+ thresholds: { ...DEFAULT_THRESHOLDS, ...(cfg.thresholds || {}), ...thresholds },
131
+ });
132
+ }
133
+
134
+ function envThresholds() {
135
+ const t = {};
136
+ const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
137
+ const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
138
+ set("minPx", "TARGET_MIN_PX");
139
+ set("aaaPx", "TARGET_AAA_PX");
140
+ return t;
141
+ }
142
+
143
+ async function main() {
144
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
145
+ const config = argv[0] || process.env.TARGET_CONFIG;
146
+ if (!config) {
147
+ console.error("✗ target-size-gate: usage: target-size-gate.mjs <config.json> (or set $TARGET_CONFIG)");
148
+ process.exit(2);
149
+ }
150
+ const report = await runTargetSizeGate({ config, thresholds: envThresholds() });
151
+ if (process.env.TARGET_REPORT) await writeFile(resolve(process.env.TARGET_REPORT), JSON.stringify(report, null, 2) + "\n");
152
+
153
+ const s = report.summary;
154
+ const line = `target-size-gate: ${s.targets} target(s) — ${s.belowAA} below AA, ${s.exempt} exempt, ${s.meetsAAA} meet AAA`;
155
+ if (!report.passed) {
156
+ console.error(`✗ ${line}`);
157
+ for (const r of report.targets) for (const f of r.findings) if (f.severity === "error") console.error(` · ${r.name}: ${f.msg}`);
158
+ process.exit(1);
159
+ }
160
+ console.log(`✓ ${line}`);
161
+ for (const n of report.notes) console.log(` · note: ${n}`);
162
+ }
163
+
164
+ if (import.meta.url === `file://${process.argv[1]}`) {
165
+ main().catch((e) => { console.error("✗ target-size-gate: error —", e.stack || e.message); process.exit(1); });
166
+ }