@bounded-systems/conformance-kit 0.5.0 → 0.7.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 +8 -0
- package/gates/ai-readability-gate.mjs +169 -0
- package/gates/conformance/conformance.mjs +47 -0
- package/gates/conformance/web-build.mjs +80 -0
- package/gates/likeness-gate.mjs +174 -0
- package/gates/opacity-contrast-gate.mjs +191 -0
- package/gates/pairing-extractor.mjs +338 -0
- package/gates/target-size-gate.mjs +166 -0
- package/gates/token-a11y.mjs +136 -0
- package/gates/typography-gate.mjs +396 -0
- package/package.json +8 -1
|
@@ -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,338 @@
|
|
|
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
|
+
// --allowlist | $PAIRING_ALLOWLIST=1 CLOSED-WORLD: `declared` is the opt-in
|
|
41
|
+
// allowlist — every extracted pairing must be declared (else an
|
|
42
|
+
// `undeclared` violation) and every declared pairing must pass.
|
|
43
|
+
// $PAIRING_MATRIX write the Markdown matrix here (else stdout).
|
|
44
|
+
// $PAIRING_REPORT write the full JSON report here.
|
|
45
|
+
// $PAIRING_GATE "1" → also run the palette gate over the union and exit 1 on
|
|
46
|
+
// any failing pair (default: report-only, exit 0).
|
|
47
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
48
|
+
import { resolve } from "node:path";
|
|
49
|
+
import { tokensFromCSS, loadTokens, resolveColor, evaluatePair, evaluatePalette, CVD_TYPES, parseHex, toHex } from "./palette-gate.mjs";
|
|
50
|
+
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// 1. Minimal CSS rule parser (zero-dep) — selectors → colour-bearing declarations
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const COLOR_PROPS = {
|
|
56
|
+
color: "fg", fill: "fg", stroke: "fg",
|
|
57
|
+
"border-color": "ui", "border-top-color": "ui", "border-bottom-color": "ui",
|
|
58
|
+
"border-left-color": "ui", "border-right-color": "ui", "outline-color": "ui",
|
|
59
|
+
"background-color": "bg", background: "bg",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Strip comments + at-rule prelude noise, then yield { selectors:[], decls:{} } rules. */
|
|
63
|
+
export function parseRules(css) {
|
|
64
|
+
const clean = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
65
|
+
const rules = [];
|
|
66
|
+
// Flatten one level of @media/@supports by stripping the at-rule wrapper braces.
|
|
67
|
+
const body = clean.replace(/@(media|supports|layer)[^{]*\{/g, "");
|
|
68
|
+
const re = /([^{}]+)\{([^{}]*)\}/g;
|
|
69
|
+
let m;
|
|
70
|
+
while ((m = re.exec(body))) {
|
|
71
|
+
const selectors = m[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
72
|
+
if (!selectors.length || selectors[0].startsWith("@")) continue;
|
|
73
|
+
const decls = {};
|
|
74
|
+
for (const d of m[2].split(";")) {
|
|
75
|
+
const i = d.indexOf(":");
|
|
76
|
+
if (i < 0) continue;
|
|
77
|
+
decls[d.slice(0, i).trim().toLowerCase()] = d.slice(i + 1).trim();
|
|
78
|
+
}
|
|
79
|
+
rules.push({ selectors, decls });
|
|
80
|
+
}
|
|
81
|
+
return rules;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolve a CSS colour VALUE (`var(--t)`, `#hex`, or a token name) to #hex, or null. */
|
|
85
|
+
export function resolveCssColor(value, map) {
|
|
86
|
+
if (value == null) return null;
|
|
87
|
+
let v = String(value).trim();
|
|
88
|
+
const varm = /var\(\s*--([a-zA-Z0-9-]+)\s*(?:,[^)]*)?\)/.exec(v);
|
|
89
|
+
if (varm) { try { return resolveColor(map, varm[1]); } catch { return null; } }
|
|
90
|
+
const hex = /#[0-9a-fA-F]{3,8}/.exec(v);
|
|
91
|
+
if (hex) { try { return resolveColor(map, hex[0].slice(0, 7)); } catch { return hex[0].slice(0, 7); } }
|
|
92
|
+
// bare token name
|
|
93
|
+
try { return resolveColor(map, v.split(/\s+/)[0]); } catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Find the token NAME whose value equals a hex (for readable matrix labels). */
|
|
97
|
+
function nameForHex(map, hex) {
|
|
98
|
+
const h = hex.toLowerCase();
|
|
99
|
+
for (const [k, v] of Object.entries(map)) { try { if (toHex(parseHex(v)).toLowerCase() === h) return k; } catch {} }
|
|
100
|
+
return hex;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
// 2. Extraction — rules → fg/bg pairings (pure)
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const ROOT_SELECTORS = new Set([":root", "html", "body", "*", ":where(html)"]);
|
|
108
|
+
|
|
109
|
+
/** Is `anc` a structural ancestor of `sel` (prefix-of-compound heuristic)? */
|
|
110
|
+
function isAncestorSelector(anc, sel) {
|
|
111
|
+
if (anc === sel) return false;
|
|
112
|
+
// descendant combinator: ".card" ancestor of ".card .title", ".card>.title", ".card.x"
|
|
113
|
+
return sel.startsWith(anc + " ") || sel.startsWith(anc + ">") || sel.startsWith(anc + " >") || sel.startsWith(anc + ":");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Derive pairings from parsed rules + token map. Returns
|
|
118
|
+
* { pairings:[{fg,bg,fgHex,bgHex,kind,source,confidence}], surfaces, foregrounds }.
|
|
119
|
+
* Pure.
|
|
120
|
+
*/
|
|
121
|
+
export function extractPairings(rules, map, opts = {}) {
|
|
122
|
+
// Per simple-selector record fg(s) and bg.
|
|
123
|
+
const flat = [];
|
|
124
|
+
for (const r of rules) {
|
|
125
|
+
let bg = null;
|
|
126
|
+
const fgs = [];
|
|
127
|
+
for (const [prop, role] of Object.entries(COLOR_PROPS)) {
|
|
128
|
+
if (!(prop in r.decls)) continue;
|
|
129
|
+
const hex = resolveCssColor(r.decls[prop], map);
|
|
130
|
+
if (!hex) continue;
|
|
131
|
+
if (role === "bg") bg = hex;
|
|
132
|
+
else fgs.push({ hex, kind: role === "ui" ? "ui" : "text", prop });
|
|
133
|
+
}
|
|
134
|
+
if (bg == null && fgs.length === 0) continue;
|
|
135
|
+
for (const sel of r.selectors) flat.push({ sel, bg, fgs });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Root surface background(s).
|
|
139
|
+
const rootBgs = flat.filter((f) => f.bg && ROOT_SELECTORS.has(f.sel.split(/[ >:]/)[0]) ).map((f) => f.bg);
|
|
140
|
+
const defaultSurface = rootBgs[0] || opts.defaultBackground || null;
|
|
141
|
+
|
|
142
|
+
// Background-declaring selectors (surfaces) for containment lookup.
|
|
143
|
+
const surfaces = flat.filter((f) => f.bg).map((f) => ({ sel: f.sel, bg: f.bg }));
|
|
144
|
+
|
|
145
|
+
const pairings = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const emit = (fgHex, bgHex, kind, source, confidence) => {
|
|
148
|
+
if (!fgHex || !bgHex || fgHex.toLowerCase() === bgHex.toLowerCase()) return;
|
|
149
|
+
const key = `${fgHex.toLowerCase()}|${bgHex.toLowerCase()}|${kind}`;
|
|
150
|
+
if (seen.has(key)) return;
|
|
151
|
+
seen.add(key);
|
|
152
|
+
pairings.push({
|
|
153
|
+
fg: nameForHex(map, fgHex), bg: nameForHex(map, bgHex),
|
|
154
|
+
fgHex, bgHex, kind, source, confidence,
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const f of flat) {
|
|
159
|
+
for (const fg of f.fgs) {
|
|
160
|
+
if (f.bg) { emit(fg.hex, f.bg, fg.kind, f.sel, "rule"); continue; }
|
|
161
|
+
// containment: nearest ancestor surface
|
|
162
|
+
const anc = surfaces.filter((s) => isAncestorSelector(s.sel, f.sel));
|
|
163
|
+
if (anc.length) for (const a of anc) emit(fg.hex, a.bg, fg.kind, `${f.sel} ⊂ ${a.sel}`, "surface");
|
|
164
|
+
else if (defaultSurface) emit(fg.hex, defaultSurface, fg.kind, `${f.sel} ⊂ :root`, "root");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { pairings, surfaces, defaultSurface };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
// 3. Matrix — evaluate every extracted pair, render Markdown (pure)
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** Evaluate each pairing through the palette gate's per-pair check → matrix rows. */
|
|
175
|
+
export function buildMatrix(pairings) {
|
|
176
|
+
return pairings.map((p) => {
|
|
177
|
+
const ev = evaluatePair({ ...p, fgHex: p.fgHex, bgHex: p.bgHex });
|
|
178
|
+
return {
|
|
179
|
+
fg: p.fg, bg: p.bg, fgHex: p.fgHex, bgHex: p.bgHex, kind: p.kind,
|
|
180
|
+
confidence: p.confidence, source: p.source,
|
|
181
|
+
wcag: ev.wcag.ratio, wcagPass: ev.checks.wcagAA,
|
|
182
|
+
apca: ev.apca ? ev.apca.absLc : null,
|
|
183
|
+
cvd: Object.fromEntries(CVD_TYPES.map((c) => [c, ev.cvd[c].ratio])),
|
|
184
|
+
cvdPass: ev.cvd.pass,
|
|
185
|
+
passed: ev.passed,
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Render the matrix as a Markdown table. Pure. */
|
|
191
|
+
export function renderMatrixMarkdown(matrix) {
|
|
192
|
+
const head = `| fg | bg | kind | WCAG | APCA Lc | ${CVD_TYPES.map((c) => c.slice(0, 4)).join(" | ")} | pass | conf |`;
|
|
193
|
+
const sep = `|${"---|".repeat(6 + CVD_TYPES.length)}`;
|
|
194
|
+
const rows = matrix.map((m) =>
|
|
195
|
+
`| ${m.fg} | ${m.bg} | ${m.kind} | ${m.wcag}:1 | ${m.apca ?? "—"} | ${CVD_TYPES.map((c) => m.cvd[c]).join(" | ")} | ${m.passed ? "✓" : "✗"} | ${m.confidence} |`);
|
|
196
|
+
return [`# Pairing matrix (${matrix.length} extracted fg×bg combos)`, "", head, sep, ...rows, ""].join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
// 4. Full run
|
|
201
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** Resolve a declared spec's pairings to {fg,bg,fgHex,bgHex,kind,name,key}. */
|
|
204
|
+
function resolveDeclared(map, spec) {
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const d of spec.pairings || []) {
|
|
207
|
+
try {
|
|
208
|
+
const fgHex = resolveColor(map, d.fg), bgHex = resolveColor(map, d.bg), kind = d.kind || "text";
|
|
209
|
+
const key = `${toHex(parseHex(fgHex)).toLowerCase()}|${toHex(parseHex(bgHex)).toLowerCase()}|${kind}`;
|
|
210
|
+
out.push({ fg: d.fg, bg: d.bg, fgHex, bgHex, kind, name: d.name, key, source: "declared", confidence: "declared" });
|
|
211
|
+
} catch { /* unresolvable token → skip */ }
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
const pairKey = (p) => `${toHex(parseHex(p.fgHex)).toLowerCase()}|${toHex(parseHex(p.bgHex)).toLowerCase()}|${p.kind}`;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Load tokens + stylesheets → extract ∪ declared → matrix + palette evaluation.
|
|
219
|
+
*
|
|
220
|
+
* `allowlist:true` switches to CLOSED-WORLD: the `declared` set is the opt-in
|
|
221
|
+
* allowlist and the gate enforces BOTH directions —
|
|
222
|
+
* 1. every DECLARED pairing must pass contrast, and
|
|
223
|
+
* 2. every pairing the CSS actually produces must be DECLARED; any extracted
|
|
224
|
+
* pairing absent from the allowlist is an `undeclared` violation.
|
|
225
|
+
* The palette envelope is evaluated over the declared (vetted) set, so surface
|
|
226
|
+
* mis-extractions can't poison it — they surface as `undeclared` to either
|
|
227
|
+
* declare (and pass) or fix in the CSS.
|
|
228
|
+
*/
|
|
229
|
+
export async function runPairingExtractor({ tokens, css = [], declared = null, defaultBackground = null, allowlist = false }) {
|
|
230
|
+
const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
|
|
231
|
+
const cssTexts = await Promise.all((Array.isArray(css) ? css : [css]).map((c) => (c.includes("{") ? c : readFile(c, "utf8"))));
|
|
232
|
+
const rules = cssTexts.flatMap((t) => parseRules(t));
|
|
233
|
+
const ext = extractPairings(rules, map, { defaultBackground });
|
|
234
|
+
|
|
235
|
+
if (allowlist) {
|
|
236
|
+
const spec = declared
|
|
237
|
+
? (typeof declared === "string" ? JSON.parse(await readFile(declared, "utf8")) : declared)
|
|
238
|
+
: { pairings: [] };
|
|
239
|
+
const declaredPairs = resolveDeclared(map, spec);
|
|
240
|
+
const allowed = new Set(declaredPairs.map((p) => p.key));
|
|
241
|
+
const undeclared = ext.pairings.filter((p) => !allowed.has(pairKey(p)));
|
|
242
|
+
const matrix = buildMatrix(declaredPairs);
|
|
243
|
+
const palette = evaluatePalette({ pairings: declaredPairs.map((p) => ({ ...p })) });
|
|
244
|
+
const failingDeclared = matrix.filter((m) => !m.passed);
|
|
245
|
+
return {
|
|
246
|
+
passed: palette.passed && undeclared.length === 0,
|
|
247
|
+
mode: "allowlist",
|
|
248
|
+
summary: {
|
|
249
|
+
declared: declaredPairs.length,
|
|
250
|
+
extracted: ext.pairings.length,
|
|
251
|
+
undeclared: undeclared.length,
|
|
252
|
+
failingDeclared: failingDeclared.length,
|
|
253
|
+
surfaces: ext.surfaces.length,
|
|
254
|
+
total: declaredPairs.length,
|
|
255
|
+
failing: failingDeclared.length + undeclared.length,
|
|
256
|
+
},
|
|
257
|
+
undeclared,
|
|
258
|
+
defaultSurface: ext.defaultSurface,
|
|
259
|
+
pairings: declaredPairs,
|
|
260
|
+
matrix,
|
|
261
|
+
palette,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Union declared pairings (resolved) in.
|
|
266
|
+
const extractedCount = ext.pairings.length;
|
|
267
|
+
let union = ext.pairings;
|
|
268
|
+
if (declared) {
|
|
269
|
+
const spec = typeof declared === "string" ? JSON.parse(await readFile(declared, "utf8")) : declared;
|
|
270
|
+
const seen = new Set(union.map((p) => `${p.fgHex.toLowerCase()}|${p.bgHex.toLowerCase()}|${p.kind}`));
|
|
271
|
+
for (const d of spec.pairings || []) {
|
|
272
|
+
try {
|
|
273
|
+
const fgHex = resolveColor(map, d.fg), bgHex = resolveColor(map, d.bg), kind = d.kind || "text";
|
|
274
|
+
const key = `${toHex(parseHex(fgHex)).toLowerCase()}|${toHex(parseHex(bgHex)).toLowerCase()}|${kind}`;
|
|
275
|
+
if (!seen.has(key)) { seen.add(key); union.push({ fg: d.fg, bg: d.bg, fgHex, bgHex, kind, source: "declared", confidence: "declared" }); }
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const matrix = buildMatrix(union);
|
|
281
|
+
const palette = evaluatePalette({ pairings: union.map((p) => ({ ...p })) });
|
|
282
|
+
return {
|
|
283
|
+
passed: palette.passed,
|
|
284
|
+
summary: {
|
|
285
|
+
extracted: extractedCount,
|
|
286
|
+
declaredAdded: union.length - extractedCount,
|
|
287
|
+
total: union.length,
|
|
288
|
+
failing: matrix.filter((m) => !m.passed).length,
|
|
289
|
+
surfaces: ext.surfaces.length,
|
|
290
|
+
},
|
|
291
|
+
defaultSurface: ext.defaultSurface,
|
|
292
|
+
pairings: union,
|
|
293
|
+
matrix,
|
|
294
|
+
palette,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function main() {
|
|
299
|
+
const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
300
|
+
const tokens = argv[0] || process.env.PAIRING_TOKENS;
|
|
301
|
+
const css = argv.slice(1);
|
|
302
|
+
if (!tokens || css.length === 0) {
|
|
303
|
+
console.error("✗ pairing-extractor: usage: pairing-extractor.mjs <tokens.(json|css)> <style1.css> [style2.css …]");
|
|
304
|
+
process.exit(2);
|
|
305
|
+
}
|
|
306
|
+
const allowlist = process.argv.includes("--allowlist") || process.env.PAIRING_ALLOWLIST === "1";
|
|
307
|
+
const report = await runPairingExtractor({ tokens, css, declared: process.env.PAIRING_DECLARED || null, allowlist });
|
|
308
|
+
if (process.env.PAIRING_REPORT) await writeFile(resolve(process.env.PAIRING_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
309
|
+
|
|
310
|
+
const md = renderMatrixMarkdown(report.matrix);
|
|
311
|
+
if (process.env.PAIRING_MATRIX) await writeFile(resolve(process.env.PAIRING_MATRIX), md + "\n");
|
|
312
|
+
else console.log(md);
|
|
313
|
+
|
|
314
|
+
const s = report.summary;
|
|
315
|
+
if (allowlist) {
|
|
316
|
+
// Closed-world: fail on undeclared OR failing declared pairings.
|
|
317
|
+
const line = `pairing-extractor [allowlist]: ${s.declared} declared, ${s.undeclared} undeclared, ${s.failingDeclared} failing declared`;
|
|
318
|
+
if (!report.passed) {
|
|
319
|
+
console.error(`✗ ${line}`);
|
|
320
|
+
for (const u of report.undeclared) console.error(` · UNDECLARED: ${u.fg}/${u.bg} [${u.kind}] — declare it or fix the CSS`);
|
|
321
|
+
for (const m of report.matrix) if (!m.passed) console.error(` · FAILS: ${m.fg}/${m.bg} [${m.kind}] WCAG ${m.wcag}:1`);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
console.error(`✓ ${line}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const line = `pairing-extractor: ${s.total} pair(s) (${s.extracted} extracted + ${s.declaredAdded} declared) — ${s.failing} failing`;
|
|
328
|
+
if (process.env.PAIRING_GATE === "1" && !report.passed) {
|
|
329
|
+
console.error(`✗ ${line}`);
|
|
330
|
+
for (const m of report.matrix) if (!m.passed) console.error(` · ${m.fg}/${m.bg} [${m.kind}] WCAG ${m.wcag}:1`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
console.error(`✓ ${line}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
337
|
+
main().catch((e) => { console.error("✗ pairing-extractor: error —", e.stack || e.message); process.exit(1); });
|
|
338
|
+
}
|