@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.
@@ -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
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // Token Accessibility suite — the UNIFIED runner. One `token-a11y.json` config drives
3
+ // every member of the suite over a single token map, and the runner FAILS CLOSED if
4
+ // ANY member fails. This is the named "thing": token-level accessibility as one gate.
5
+ //
6
+ // Members (each a standalone gate, documented in TOKEN-A11Y.md):
7
+ // · palette — palette-gate.mjs CVD-safe / APCA / non-text contrast
8
+ // · pairing — pairing-extractor.mjs derive fg×bg pairings from CSS usage,
9
+ // then feed the palette check (coverage without hand-listing)
10
+ // · typography — typography-gate.mjs line-height / spacing / size / weight
11
+ // · targetSize — target-size-gate.mjs interactive target ≥ 24×24 (2.5.8)
12
+ // · opacity — opacity-contrast-gate effective contrast of translucent fg
13
+ // · likeness — likeness-gate.mjs near-duplicate + confusable categoricals
14
+ //
15
+ // node gates/token-a11y.mjs <token-a11y.json> # (or $TOKEN_A11Y_CONFIG)
16
+ //
17
+ // CONFIG (every member is OPTIONAL — only declared members run):
18
+ // { "tokens": "brand/tokens/tokens.css", // default token map (path or map)
19
+ // "palette": { "pairings":[…], "categorical":[…], "thresholds":{…} } | "pairings.json",
20
+ // "pairing": { "css":["a.css","b.css"], "declared":"pairings.json", "gate":true,
21
+ // "allowlist":true }, // closed-world: `declared` is the opt-in
22
+ // // allowlist; any pairing the CSS produces that ISN'T declared is a
23
+ // // violation, and declared pairings must pass. Palette envelope is
24
+ // // evaluated over the vetted declared set (no surface false-positives).
25
+ // "typography":{ "tokens":"…", "body":["body"], "thresholds":{…} } | "typo.json",
26
+ // "targetSize":{ "targets":[…], "thresholds":{…} } | "targets.json",
27
+ // "opacity": { "usages":[…], "opacityTokens":{…} } | "usages.json",
28
+ // "likeness": { "categorical":[…], "thresholds":{…} } | "likeness.json" }
29
+ // A member may set its own `tokens` to override the top-level map.
30
+ // $TOKEN_A11Y_REPORT writes the aggregate JSON report.
31
+ import { readFile, writeFile } from "node:fs/promises";
32
+ import { resolve, dirname, isAbsolute, join } from "node:path";
33
+ import { runPaletteGate } from "./palette-gate.mjs";
34
+ import { runTypographyGate } from "./typography-gate.mjs";
35
+ import { runTargetSizeGate } from "./target-size-gate.mjs";
36
+ import { runOpacityContrastGate } from "./opacity-contrast-gate.mjs";
37
+ import { runLikenessGate } from "./likeness-gate.mjs";
38
+ import { runPairingExtractor } from "./pairing-extractor.mjs";
39
+
40
+ const MEMBERS = ["palette", "pairing", "typography", "targetSize", "opacity", "likeness"];
41
+
42
+ /** Resolve a path/config value relative to the config file's directory. */
43
+ function rel(base, p) {
44
+ if (typeof p !== "string") return p;
45
+ return isAbsolute(p) ? p : join(base, p);
46
+ }
47
+
48
+ /**
49
+ * Run the suite from a parsed config. `base` is the dir paths resolve against.
50
+ * Returns the aggregate report. Pure-ish (I/O only via the member runners).
51
+ */
52
+ export async function runTokenA11y(config, base = ".") {
53
+ const tokens = (m) => rel(base, m?.tokens ?? config.tokens);
54
+ const members = {};
55
+ const run = async (name, fn) => { try { members[name] = { ...(await fn()), error: null }; } catch (e) { members[name] = { passed: false, error: String(e.message || e) }; } };
56
+
57
+ if (config.palette) {
58
+ const m = typeof config.palette === "string" ? rel(base, config.palette) : config.palette;
59
+ await run("palette", () => runPaletteGate({ tokens: tokens(typeof m === "object" ? m : {}), pairings: m }));
60
+ }
61
+ if (config.pairing) {
62
+ const m = config.pairing;
63
+ await run("pairing", async () => {
64
+ const r = await runPairingExtractor({
65
+ tokens: tokens(m), css: (m.css || []).map((c) => rel(base, c)),
66
+ declared: m.declared ? rel(base, m.declared) : null,
67
+ allowlist: m.allowlist || false,
68
+ });
69
+ // Allowlist (closed-world) gates by default — undeclared pairings are
70
+ // violations. Extraction-only mode is report-only unless `gate:true`.
71
+ return (m.allowlist || m.gate) ? r : { ...r, passed: true, gated: false };
72
+ });
73
+ }
74
+ if (config.typography) {
75
+ const m = typeof config.typography === "string" ? rel(base, config.typography) : config.typography;
76
+ await run("typography", () => runTypographyGate({ tokens: tokens(typeof m === "object" ? m : {}), config: m }));
77
+ }
78
+ if (config.targetSize) {
79
+ const m = typeof config.targetSize === "string" ? rel(base, config.targetSize) : config.targetSize;
80
+ await run("targetSize", () => runTargetSizeGate({ config: m }));
81
+ }
82
+ if (config.opacity) {
83
+ const m = typeof config.opacity === "string" ? rel(base, config.opacity) : config.opacity;
84
+ await run("opacity", () => runOpacityContrastGate({ tokens: tokens(typeof m === "object" ? m : {}), usages: m }));
85
+ }
86
+ if (config.likeness) {
87
+ const m = typeof config.likeness === "string" ? rel(base, config.likeness) : config.likeness;
88
+ await run("likeness", () => runLikenessGate({ tokens: tokens(typeof m === "object" ? m : {}), config: m }));
89
+ }
90
+
91
+ const ran = Object.keys(members);
92
+ const failing = ran.filter((k) => members[k].passed === false);
93
+ return {
94
+ passed: failing.length === 0,
95
+ members: Object.fromEntries(MEMBERS.filter((k) => k in members).map((k) => [k, members[k]])),
96
+ summary: { ran, passed: ran.filter((k) => members[k].passed !== false), failing },
97
+ };
98
+ }
99
+
100
+ /** One-line status per member, for the CLI. */
101
+ function memberLine(name, m) {
102
+ if (m.error) return ` ✗ ${name}: error — ${m.error}`;
103
+ const s = m.summary || {};
104
+ const tail =
105
+ name === "palette" ? `${s.pairs} pair(s), ${s.failingPairs} failing, ${s.categoricalCollapses} collapse(s)`
106
+ : name === "pairing" ? (m.mode === "allowlist"
107
+ ? `${s.declared} declared, ${s.undeclared} undeclared, ${s.failingDeclared} failing`
108
+ : `${s.total} pair(s), ${s.failing} failing${m.gated === false ? " (report-only)" : ""}`)
109
+ : name === "typography" ? `${s.styles} style(s), ${s.errors} error(s), ${s.warnings} warn(s)`
110
+ : name === "targetSize" ? `${s.targets} target(s), ${s.belowAA} below AA${m.coverage === "none" ? " (none declared)" : ""}`
111
+ : name === "opacity" ? `${s.usages} usage(s), ${s.failing} failing`
112
+ : name === "likeness" ? `${s.nearDuplicates} near-dup(s), ${s.categoricalCollapses} collapse(s)`
113
+ : "";
114
+ return ` ${m.passed ? "✓" : "✗"} ${name}: ${tail}`;
115
+ }
116
+
117
+ async function main() {
118
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
119
+ const configPath = argv[0] || process.env.TOKEN_A11Y_CONFIG;
120
+ if (!configPath) {
121
+ console.error("✗ token-a11y: usage: token-a11y.mjs <token-a11y.json> (or set $TOKEN_A11Y_CONFIG)");
122
+ process.exit(2);
123
+ }
124
+ const config = JSON.parse(await readFile(configPath, "utf8"));
125
+ const report = await runTokenA11y(config, dirname(resolve(configPath)));
126
+ if (process.env.TOKEN_A11Y_REPORT) await writeFile(resolve(process.env.TOKEN_A11Y_REPORT), JSON.stringify(report, null, 2) + "\n");
127
+
128
+ const lines = Object.entries(report.members).map(([k, m]) => memberLine(k, m));
129
+ const head = `token-a11y: ${report.summary.ran.length} member(s) — ${report.summary.failing.length} failing`;
130
+ if (!report.passed) { console.error(`✗ ${head}`); for (const l of lines) console.error(l); process.exit(1); }
131
+ console.log(`✓ ${head}`); for (const l of lines) console.log(l);
132
+ }
133
+
134
+ if (import.meta.url === `file://${process.argv[1]}`) {
135
+ main().catch((e) => { console.error("✗ token-a11y: error —", e.stack || e.message); process.exit(1); });
136
+ }