@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,128 @@
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
+ // "typography":{ "tokens":"…", "body":["body"], "thresholds":{…} } | "typo.json",
22
+ // "targetSize":{ "targets":[…], "thresholds":{…} } | "targets.json",
23
+ // "opacity": { "usages":[…], "opacityTokens":{…} } | "usages.json",
24
+ // "likeness": { "categorical":[…], "thresholds":{…} } | "likeness.json" }
25
+ // A member may set its own `tokens` to override the top-level map.
26
+ // $TOKEN_A11Y_REPORT writes the aggregate JSON report.
27
+ import { readFile, writeFile } from "node:fs/promises";
28
+ import { resolve, dirname, isAbsolute, join } from "node:path";
29
+ import { runPaletteGate } from "./palette-gate.mjs";
30
+ import { runTypographyGate } from "./typography-gate.mjs";
31
+ import { runTargetSizeGate } from "./target-size-gate.mjs";
32
+ import { runOpacityContrastGate } from "./opacity-contrast-gate.mjs";
33
+ import { runLikenessGate } from "./likeness-gate.mjs";
34
+ import { runPairingExtractor } from "./pairing-extractor.mjs";
35
+
36
+ const MEMBERS = ["palette", "pairing", "typography", "targetSize", "opacity", "likeness"];
37
+
38
+ /** Resolve a path/config value relative to the config file's directory. */
39
+ function rel(base, p) {
40
+ if (typeof p !== "string") return p;
41
+ return isAbsolute(p) ? p : join(base, p);
42
+ }
43
+
44
+ /**
45
+ * Run the suite from a parsed config. `base` is the dir paths resolve against.
46
+ * Returns the aggregate report. Pure-ish (I/O only via the member runners).
47
+ */
48
+ export async function runTokenA11y(config, base = ".") {
49
+ const tokens = (m) => rel(base, m?.tokens ?? config.tokens);
50
+ const members = {};
51
+ const run = async (name, fn) => { try { members[name] = { ...(await fn()), error: null }; } catch (e) { members[name] = { passed: false, error: String(e.message || e) }; } };
52
+
53
+ if (config.palette) {
54
+ const m = typeof config.palette === "string" ? rel(base, config.palette) : config.palette;
55
+ await run("palette", () => runPaletteGate({ tokens: tokens(typeof m === "object" ? m : {}), pairings: m }));
56
+ }
57
+ if (config.pairing) {
58
+ const m = config.pairing;
59
+ await run("pairing", async () => {
60
+ const r = await runPairingExtractor({
61
+ tokens: tokens(m), css: (m.css || []).map((c) => rel(base, c)),
62
+ declared: m.declared ? rel(base, m.declared) : null,
63
+ });
64
+ // Report-only unless `gate:true`.
65
+ return m.gate ? r : { ...r, passed: true, gated: false };
66
+ });
67
+ }
68
+ if (config.typography) {
69
+ const m = typeof config.typography === "string" ? rel(base, config.typography) : config.typography;
70
+ await run("typography", () => runTypographyGate({ tokens: tokens(typeof m === "object" ? m : {}), config: m }));
71
+ }
72
+ if (config.targetSize) {
73
+ const m = typeof config.targetSize === "string" ? rel(base, config.targetSize) : config.targetSize;
74
+ await run("targetSize", () => runTargetSizeGate({ config: m }));
75
+ }
76
+ if (config.opacity) {
77
+ const m = typeof config.opacity === "string" ? rel(base, config.opacity) : config.opacity;
78
+ await run("opacity", () => runOpacityContrastGate({ tokens: tokens(typeof m === "object" ? m : {}), usages: m }));
79
+ }
80
+ if (config.likeness) {
81
+ const m = typeof config.likeness === "string" ? rel(base, config.likeness) : config.likeness;
82
+ await run("likeness", () => runLikenessGate({ tokens: tokens(typeof m === "object" ? m : {}), config: m }));
83
+ }
84
+
85
+ const ran = Object.keys(members);
86
+ const failing = ran.filter((k) => members[k].passed === false);
87
+ return {
88
+ passed: failing.length === 0,
89
+ members: Object.fromEntries(MEMBERS.filter((k) => k in members).map((k) => [k, members[k]])),
90
+ summary: { ran, passed: ran.filter((k) => members[k].passed !== false), failing },
91
+ };
92
+ }
93
+
94
+ /** One-line status per member, for the CLI. */
95
+ function memberLine(name, m) {
96
+ if (m.error) return ` ✗ ${name}: error — ${m.error}`;
97
+ const s = m.summary || {};
98
+ const tail =
99
+ name === "palette" ? `${s.pairs} pair(s), ${s.failingPairs} failing, ${s.categoricalCollapses} collapse(s)`
100
+ : name === "pairing" ? `${s.total} pair(s), ${s.failing} failing${m.gated === false ? " (report-only)" : ""}`
101
+ : name === "typography" ? `${s.styles} style(s), ${s.errors} error(s), ${s.warnings} warn(s)`
102
+ : name === "targetSize" ? `${s.targets} target(s), ${s.belowAA} below AA${m.coverage === "none" ? " (none declared)" : ""}`
103
+ : name === "opacity" ? `${s.usages} usage(s), ${s.failing} failing`
104
+ : name === "likeness" ? `${s.nearDuplicates} near-dup(s), ${s.categoricalCollapses} collapse(s)`
105
+ : "";
106
+ return ` ${m.passed ? "✓" : "✗"} ${name}: ${tail}`;
107
+ }
108
+
109
+ async function main() {
110
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
111
+ const configPath = argv[0] || process.env.TOKEN_A11Y_CONFIG;
112
+ if (!configPath) {
113
+ console.error("✗ token-a11y: usage: token-a11y.mjs <token-a11y.json> (or set $TOKEN_A11Y_CONFIG)");
114
+ process.exit(2);
115
+ }
116
+ const config = JSON.parse(await readFile(configPath, "utf8"));
117
+ const report = await runTokenA11y(config, dirname(resolve(configPath)));
118
+ if (process.env.TOKEN_A11Y_REPORT) await writeFile(resolve(process.env.TOKEN_A11Y_REPORT), JSON.stringify(report, null, 2) + "\n");
119
+
120
+ const lines = Object.entries(report.members).map(([k, m]) => memberLine(k, m));
121
+ const head = `token-a11y: ${report.summary.ran.length} member(s) — ${report.summary.failing.length} failing`;
122
+ if (!report.passed) { console.error(`✗ ${head}`); for (const l of lines) console.error(l); process.exit(1); }
123
+ console.log(`✓ ${head}`); for (const l of lines) console.log(l);
124
+ }
125
+
126
+ if (import.meta.url === `file://${process.argv[1]}`) {
127
+ main().catch((e) => { console.error("✗ token-a11y: error —", e.stack || e.message); process.exit(1); });
128
+ }
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ // Typography gate — STATIC analysis over a site/brand's TYPE tokens (the
3
+ // font-size / line-height / weight / letter-&-word-&-paragraph-spacing recipes),
4
+ // member of the Token Accessibility suite. Like the palette gate it reasons about
5
+ // the TOKENS themselves — the values a design system ships — not a rendered page,
6
+ // so it answers a question axe cannot: "do these type tokens PERMIT accessible
7
+ // text, or do they bake in a barrier?"
8
+ //
9
+ // Four checks, each mapped to a WCAG success criterion:
10
+ //
11
+ // 1. BODY LINE-HEIGHT ≥ 1.5 (WCAG 2.2 SC 1.4.12 Text Spacing)
12
+ // Body/paragraph styles must author a line-height of at least 1.5× the font
13
+ // size. 1.4.12 is written as a USER-OVERRIDE criterion (no loss of content
14
+ // when the reader sets line-height to 1.5) — a static token check approximates
15
+ // the spirit by requiring the shipped body token not undercut that floor, and
16
+ // by requiring it be expressed in an OVERRIDABLE unit (see check 2).
17
+ //
18
+ // 2. TEXT-SPACING ACHIEVABILITY (WCAG 2.2 SC 1.4.12)
19
+ // The criterion guarantees the reader can set letter-spacing ≥ 0.12em, word-
20
+ // spacing ≥ 0.16em, paragraph-spacing ≥ 2em and line-height ≥ 1.5 with no loss
21
+ // of content. From tokens alone we cannot prove "no loss of content", but we
22
+ // CAN prove the tokens don't PRECLUDE those overrides: spacing/line-height must
23
+ // be expressed in RELATIVE, overridable units (unitless / em / rem / %), never
24
+ // pinned in px (which a user stylesheet without !important cannot scale past a
25
+ // fixed line-box). A px-pinned line-height or letter-spacing on body is flagged.
26
+ //
27
+ // 3. MINIMUM FONT SIZE (WCAG 1.4.4 Resize Text — supporting; readability)
28
+ // Body text should be ≥ ~16px (recommended) and MUST clear a hard floor of
29
+ // ~12px. Below the floor → error; between floor and recommended → warning. Plus
30
+ // a modular-scale sanity check: the size ramp should be monotonic and each step
31
+ // within a sane ratio band (no inversions, no absurd jumps, no exact dups).
32
+ //
33
+ // 4. WEIGHT × SIZE LEGIBILITY (APCA cross-check; WCAG 1.4.3/1.4.6 spirit)
34
+ // Hairline/thin weights (≤ 200) at small sizes render as low-contrast strokes.
35
+ // Flag thin weight below a size threshold. AND cross-link the palette gate:
36
+ // report the APCA Lc a style of this size/weight will REQUIRE wherever it is
37
+ // coloured (thin + small ⇒ higher Lc), via palette-gate's `apcaMinLc`.
38
+ //
39
+ // Zero-dependency. Colour-science / APCA primitives are imported from the sibling
40
+ // palette gate (no duplication). The pure functions are exported for unit testing;
41
+ // the CLI is a thin wrapper that FAILS CLOSED (exit 1) on any error-severity finding.
42
+ //
43
+ // node gates/typography-gate.mjs <type-tokens.(json|css)> [config.json]
44
+ //
45
+ // INPUTS the consumer supplies (nothing about any one brand is hard-coded):
46
+ // argv[2] / $TYPO_TOKENS a DTCG `tokens.json` (its `$type:"typography"` recipes,
47
+ // with `{size.*}` aliases resolved) OR a `tokens.css`
48
+ // (`.bs-text-*{ font-size; line-height; … }` recipe classes).
49
+ // argv[3] / $TYPO_CONFIG a `config.json` declaring which styles are BODY text
50
+ // (`{ "body":["body"], "thresholds":{…}, "styles":[…] }`).
51
+ // Only the consumer knows which token is the body recipe;
52
+ // line-height & min-size rules apply to those. `styles[]`
53
+ // can inline extra styles (for a CSS-less consumer) or
54
+ // override parsed ones.
55
+ // $TYPO_REPORT path to write the machine-readable JSON report.
56
+ //
57
+ // Thresholds are config-driven (`config.json` `thresholds` ⊕ env) and fail closed:
58
+ // $TYPO_MIN_BODY_LINE_HEIGHT (default 1.5) SC 1.4.12 body line-height floor
59
+ // $TYPO_REC_BODY_PX (default 16) recommended body min (warn below)
60
+ // $TYPO_MIN_BODY_PX (default 12) hard body min (error below)
61
+ // $TYPO_MIN_LETTER_EM (default 0.12) 1.4.12 letter-spacing override target
62
+ // $TYPO_MIN_WORD_EM (default 0.16) 1.4.12 word-spacing override target
63
+ // $TYPO_MIN_PARA_EM (default 2) 1.4.12 paragraph-spacing override target
64
+ // $TYPO_THIN_WEIGHT (default 200) weight ≤ this is "thin/hairline"
65
+ // $TYPO_THIN_MIN_PX (default 24) thin weight below this px → error
66
+ // $TYPO_SCALE_MIN_RATIO (default 1.05) min step ratio in the size ramp
67
+ // $TYPO_SCALE_MAX_RATIO (default 2.4) max step ratio in the size ramp
68
+ import { readFile, writeFile } from "node:fs/promises";
69
+ import { resolve } from "node:path";
70
+ import { apcaMinLc } from "./palette-gate.mjs";
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+ // 1. Value parsing — dimensions, line-heights, spacings (pure)
74
+ // Refs: CSS Values & Units (px/em/rem/%); a `rem`/`em` is resolved against the
75
+ // document/element font size (we assume a 16px root, the browser default).
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ const ROOT_PX = 16; // CSS initial root font-size (browser default).
79
+
80
+ /** A length string → { px, unit, value } or null. Supports px / rem / em / pt / %. */
81
+ export function parseLength(value, contextPx = ROOT_PX) {
82
+ if (value == null) return null;
83
+ if (typeof value === "number") return { px: value, unit: "px", value };
84
+ const m = /^(-?[0-9]*\.?[0-9]+)\s*(px|rem|em|pt|%)?$/.exec(String(value).trim());
85
+ if (!m) return null;
86
+ const n = parseFloat(m[1]);
87
+ const unit = m[2] || "px";
88
+ let px;
89
+ if (unit === "px") px = n;
90
+ else if (unit === "rem") px = n * ROOT_PX;
91
+ else if (unit === "em") px = n * contextPx;
92
+ else if (unit === "pt") px = (n * 96) / 72;
93
+ else if (unit === "%") px = (n / 100) * contextPx;
94
+ else px = n;
95
+ return { px, unit, value: n };
96
+ }
97
+
98
+ /** A line-height (unitless | px | em | rem | %) → its RATIO to the font size. */
99
+ export function parseLineHeight(value, fontSizePx) {
100
+ if (value == null) return null;
101
+ const s = String(value).trim();
102
+ if (/^-?[0-9]*\.?[0-9]+$/.test(s)) return { ratio: parseFloat(s), unit: "unitless", overridable: true };
103
+ const len = parseLength(s, fontSizePx);
104
+ if (!len) return null;
105
+ const overridable = len.unit !== "px" && len.unit !== "pt"; // px/pt pin a fixed box
106
+ return { ratio: fontSizePx ? len.px / fontSizePx : null, unit: len.unit, overridable };
107
+ }
108
+
109
+ /** A letter/word/paragraph spacing → em relative to font size. */
110
+ export function parseSpacingEm(value, fontSizePx) {
111
+ if (value == null) return null;
112
+ const s = String(value).trim();
113
+ if (s === "normal" || s === "0") return { em: 0, unit: s === "0" ? "unitless" : "normal", overridable: true };
114
+ const len = parseLength(s, fontSizePx);
115
+ if (!len) return null;
116
+ if (len.unit === "em") return { em: len.value, unit: "em", overridable: true };
117
+ if (len.unit === "rem" || len.unit === "%") return { em: fontSizePx ? len.px / fontSizePx : null, unit: len.unit, overridable: true };
118
+ // px/pt: a fixed spacing the reader's relative override can't supersede cleanly.
119
+ return { em: fontSizePx ? len.px / fontSizePx : null, unit: len.unit, overridable: false };
120
+ }
121
+
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+ // 2. Token loading — DTCG `$type:"typography"` recipes, or `tokens.css` classes
124
+ // ─────────────────────────────────────────────────────────────────────────────
125
+
126
+ /** Flatten DTCG `size`/`dimension` tokens → { "size.text-body": "15px", … } (aliases kept). */
127
+ function flattenDimensions(json) {
128
+ const out = {};
129
+ const walk = (node, path) => {
130
+ if (node && typeof node === "object") {
131
+ if (typeof node.$value === "string" && (node.$type === "dimension" || /px|rem|em|pt|%$/.test(node.$value))) {
132
+ out[path.join(".")] = node.$value;
133
+ }
134
+ for (const [k, v] of Object.entries(node)) if (!k.startsWith("$")) walk(v, [...path, k]);
135
+ }
136
+ };
137
+ walk(json, []);
138
+ return out;
139
+ }
140
+
141
+ /** Resolve a `{dotted.alias}` (or literal) against a flat map. */
142
+ function resolveAlias(v, flat, seen = new Set()) {
143
+ const m = /^\{(.+)\}$/.exec(String(v).trim());
144
+ if (!m) return v;
145
+ if (seen.has(m[1]) || !(m[1] in flat)) return v;
146
+ return resolveAlias(flat[m[1]], flat, new Set([...seen, m[1]]));
147
+ }
148
+
149
+ /**
150
+ * Parse a DTCG tokens.json into normalized type styles:
151
+ * { name → { fontSizePx, lineHeight{ratio,unit,overridable}, fontWeight,
152
+ * letterSpacing{em,unit,overridable}, wordSpacing?, paragraphSpacing?,
153
+ * fontFamily, raw } }
154
+ * `$type:"typography"` recipes are read from any tier (brand uses a `text` tier).
155
+ */
156
+ export function typesFromDTCG(json) {
157
+ const dims = flattenDimensions(json);
158
+ const styles = {};
159
+ const walk = (node, path) => {
160
+ if (node && typeof node === "object") {
161
+ if (node.$type === "typography" && node.$value && typeof node.$value === "object") {
162
+ styles[path.join(".")] = normalizeStyle(node.$value, dims);
163
+ }
164
+ for (const [k, v] of Object.entries(node)) if (!k.startsWith("$")) walk(v, [...path, k]);
165
+ }
166
+ };
167
+ walk(json, []);
168
+ return styles;
169
+ }
170
+
171
+ function normalizeStyle(v, dims = {}) {
172
+ const fsRaw = resolveAlias(v.fontSize, dims);
173
+ const fontSizePx = parseLength(fsRaw)?.px ?? null;
174
+ return {
175
+ fontSizePx,
176
+ fontSizeRaw: fsRaw,
177
+ lineHeight: v.lineHeight != null ? parseLineHeight(v.lineHeight, fontSizePx) : null,
178
+ fontWeight: v.fontWeight != null ? Number(v.fontWeight) : null,
179
+ letterSpacing: v.letterSpacing != null ? parseSpacingEm(v.letterSpacing, fontSizePx) : null,
180
+ wordSpacing: v.wordSpacing != null ? parseSpacingEm(v.wordSpacing, fontSizePx) : null,
181
+ paragraphSpacing: v.paragraphSpacing != null ? parseSpacingEm(v.paragraphSpacing, fontSizePx) : null,
182
+ fontFamily: v.fontFamily ?? null,
183
+ raw: v,
184
+ };
185
+ }
186
+
187
+ /** Parse a tokens.css of `.name { font-size; line-height; font-weight; letter-spacing }` recipes. */
188
+ export function typesFromCSS(css) {
189
+ const styles = {};
190
+ const re = /\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g;
191
+ let m;
192
+ while ((m = re.exec(css))) {
193
+ const name = m[1], body = m[2];
194
+ const decl = {};
195
+ for (const d of body.split(";")) {
196
+ const i = d.indexOf(":");
197
+ if (i < 0) continue;
198
+ decl[d.slice(0, i).trim().toLowerCase()] = d.slice(i + 1).trim();
199
+ }
200
+ if (!("font-size" in decl) && !("line-height" in decl)) continue;
201
+ styles[name] = normalizeStyle({
202
+ fontSize: decl["font-size"],
203
+ lineHeight: decl["line-height"],
204
+ fontWeight: decl["font-weight"],
205
+ letterSpacing: decl["letter-spacing"],
206
+ wordSpacing: decl["word-spacing"],
207
+ fontFamily: decl["font-family"],
208
+ });
209
+ }
210
+ return styles;
211
+ }
212
+
213
+ export async function loadTypeTokens(path) {
214
+ const raw = await readFile(path, "utf8");
215
+ if (path.endsWith(".json")) return typesFromDTCG(JSON.parse(raw));
216
+ if (path.endsWith(".css")) return typesFromCSS(raw);
217
+ try { return typesFromDTCG(JSON.parse(raw)); } catch { return typesFromCSS(raw); }
218
+ }
219
+
220
+ // ─────────────────────────────────────────────────────────────────────────────
221
+ // 3. Thresholds + per-style evaluation (pure)
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+
224
+ export const DEFAULT_THRESHOLDS = {
225
+ minBodyLineHeight: 1.5, // SC 1.4.12
226
+ recBodyPx: 16, // recommended body min (warn below)
227
+ minBodyPx: 12, // hard body min (error below)
228
+ minLetterEm: 0.12, // SC 1.4.12 target
229
+ minWordEm: 0.16, // SC 1.4.12 target
230
+ minParaEm: 2, // SC 1.4.12 target
231
+ thinWeight: 200, // ≤ this is hairline/thin
232
+ thinMinPx: 24, // thin weight below this px → error
233
+ scaleMinRatio: 1.05, // size-ramp step floor
234
+ scaleMaxRatio: 2.4, // size-ramp step ceiling
235
+ };
236
+
237
+ const round2 = (n) => (n == null ? null : Math.round(n * 100) / 100);
238
+
239
+ /** Evaluate ONE type style. `isBody` drives which checks are hard. Pure. */
240
+ export function evaluateStyle(name, style, isBody, thresholds = DEFAULT_THRESHOLDS) {
241
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
242
+ const findings = [];
243
+ const add = (severity, sc, msg, data) => findings.push({ severity, sc, msg, ...data });
244
+ const px = style.fontSizePx;
245
+
246
+ // 1. Body line-height ≥ 1.5 (SC 1.4.12).
247
+ if (isBody && style.lineHeight) {
248
+ if (style.lineHeight.ratio != null && style.lineHeight.ratio + 1e-9 < t.minBodyLineHeight) {
249
+ add("error", "1.4.12", `body line-height ${round2(style.lineHeight.ratio)} < ${t.minBodyLineHeight}`, { lineHeight: round2(style.lineHeight.ratio) });
250
+ }
251
+ }
252
+
253
+ // 2. Text-spacing achievability — relative, overridable units (SC 1.4.12).
254
+ if (style.lineHeight && style.lineHeight.overridable === false) {
255
+ add(isBody ? "error" : "warn", "1.4.12", `line-height pinned in ${style.lineHeight.unit} (not user-overridable)`, { unit: style.lineHeight.unit });
256
+ }
257
+ for (const [prop, sp] of [["letterSpacing", style.letterSpacing], ["wordSpacing", style.wordSpacing], ["paragraphSpacing", style.paragraphSpacing]]) {
258
+ if (sp && sp.overridable === false) {
259
+ add(isBody ? "error" : "warn", "1.4.12", `${prop} pinned in ${sp.unit} (not user-overridable, can clip 1.4.12 spacing)`, { prop, unit: sp.unit });
260
+ }
261
+ }
262
+
263
+ // 3. Minimum font size (body) — hard floor + recommended (SC 1.4.4 / readability).
264
+ if (isBody && px != null) {
265
+ if (px + 1e-9 < t.minBodyPx) add("error", "1.4.4", `body font-size ${px}px < hard floor ${t.minBodyPx}px`, { px });
266
+ else if (px + 1e-9 < t.recBodyPx) add("warn", "1.4.4", `body font-size ${px}px < recommended ${t.recBodyPx}px`, { px });
267
+ }
268
+
269
+ // 4. Weight × size legibility + APCA cross-check (SC 1.4.3/1.4.6 spirit; APCA).
270
+ if (px != null && style.fontWeight != null && style.fontWeight <= t.thinWeight && px < t.thinMinPx) {
271
+ add("error", "1.4.8", `thin weight ${style.fontWeight} at ${px}px (< ${t.thinMinPx}px) — hairline strokes lose legibility`, { weight: style.fontWeight, px });
272
+ }
273
+ const requiredLc = px != null ? apcaMinLc({ sizePx: px, weight: style.fontWeight ?? 400 }) : null;
274
+
275
+ const errors = findings.filter((f) => f.severity === "error").length;
276
+ return {
277
+ name,
278
+ isBody,
279
+ fontSizePx: px,
280
+ fontWeight: style.fontWeight ?? null,
281
+ lineHeight: style.lineHeight ? round2(style.lineHeight.ratio) : null,
282
+ letterSpacingEm: style.letterSpacing ? round2(style.letterSpacing.em) : null,
283
+ requiredApcaLc: requiredLc, // cross-link: wherever this style is coloured, palette gate must clear this Lc
284
+ findings,
285
+ passed: errors === 0,
286
+ };
287
+ }
288
+
289
+ /** Modular-scale sanity over the distinct font sizes used. Pure. */
290
+ export function evaluateScale(sizesPx, thresholds = DEFAULT_THRESHOLDS) {
291
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
292
+ const sizes = [...new Set(sizesPx.filter((n) => typeof n === "number"))].sort((a, b) => a - b);
293
+ const findings = [];
294
+ for (let i = 1; i < sizes.length; i++) {
295
+ const ratio = sizes[i] / sizes[i - 1];
296
+ if (ratio + 1e-9 < t.scaleMinRatio) findings.push({ severity: "warn", msg: `near-duplicate scale step ${sizes[i - 1]}px→${sizes[i]}px (ratio ${round2(ratio)} < ${t.scaleMinRatio})`, from: sizes[i - 1], to: sizes[i], ratio: round2(ratio) });
297
+ else if (ratio - 1e-9 > t.scaleMaxRatio) findings.push({ severity: "warn", msg: `large scale jump ${sizes[i - 1]}px→${sizes[i]}px (ratio ${round2(ratio)} > ${t.scaleMaxRatio})`, from: sizes[i - 1], to: sizes[i], ratio: round2(ratio) });
298
+ }
299
+ return { sizes, steps: Math.max(0, sizes.length - 1), findings };
300
+ }
301
+
302
+ /** Whole-typography evaluation → fail-closed report. Pure. */
303
+ export function evaluateTypography({ styles = {}, body = [], thresholds = DEFAULT_THRESHOLDS } = {}) {
304
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
305
+ const bodySet = new Set(body);
306
+ const results = Object.entries(styles).map(([name, s]) => evaluateStyle(name, s, bodySet.has(name) || bodySet.has(name.split(".").pop()), t));
307
+ const scale = evaluateScale(Object.values(styles).map((s) => s.fontSizePx), t);
308
+
309
+ const allFindings = [...results.flatMap((r) => r.findings.map((f) => ({ style: r.name, ...f }))), ...scale.findings.map((f) => ({ style: "<scale>", ...f }))];
310
+ const errors = allFindings.filter((f) => f.severity === "error");
311
+ const warns = allFindings.filter((f) => f.severity === "warn");
312
+
313
+ return {
314
+ passed: errors.length === 0,
315
+ thresholds: t,
316
+ summary: {
317
+ styles: results.length,
318
+ bodyStyles: results.filter((r) => r.isBody).length,
319
+ errors: errors.length,
320
+ warnings: warns.length,
321
+ },
322
+ styles: results,
323
+ scale,
324
+ findings: allFindings,
325
+ // Envelope a future lone `typography.*` criterion can consume.
326
+ typography: {
327
+ bodyLineHeight: !errors.some((f) => f.sc === "1.4.12" && /line-height/.test(f.msg)),
328
+ textSpacingAchievable: !errors.some((f) => f.sc === "1.4.12" && /pinned/.test(f.msg)),
329
+ minFontSize: !errors.some((f) => f.sc === "1.4.4"),
330
+ weightLegibility: !errors.some((f) => f.sc === "1.4.8"),
331
+ },
332
+ };
333
+ }
334
+
335
+ /** Full run: load tokens + config → evaluate. Exposed for tests. */
336
+ export async function runTypographyGate({ tokens, config = {}, thresholds = {} }) {
337
+ const parsed = typeof tokens === "string" ? await loadTypeTokens(tokens) : tokens;
338
+ const cfg = typeof config === "string" ? JSON.parse(await readFile(config, "utf8")) : config;
339
+ const styles = { ...parsed };
340
+ for (const s of cfg.styles || []) styles[s.name] = normalizeStyle(s);
341
+ return evaluateTypography({
342
+ styles,
343
+ body: cfg.body || [],
344
+ thresholds: { ...DEFAULT_THRESHOLDS, ...(cfg.thresholds || {}), ...thresholds },
345
+ });
346
+ }
347
+
348
+ // ─────────────────────────────────────────────────────────────────────────────
349
+ // 4. CLI
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+
352
+ function envThresholds() {
353
+ const t = {};
354
+ const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
355
+ const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
356
+ set("minBodyLineHeight", "TYPO_MIN_BODY_LINE_HEIGHT");
357
+ set("recBodyPx", "TYPO_REC_BODY_PX");
358
+ set("minBodyPx", "TYPO_MIN_BODY_PX");
359
+ set("minLetterEm", "TYPO_MIN_LETTER_EM");
360
+ set("minWordEm", "TYPO_MIN_WORD_EM");
361
+ set("minParaEm", "TYPO_MIN_PARA_EM");
362
+ set("thinWeight", "TYPO_THIN_WEIGHT");
363
+ set("thinMinPx", "TYPO_THIN_MIN_PX");
364
+ set("scaleMinRatio", "TYPO_SCALE_MIN_RATIO");
365
+ set("scaleMaxRatio", "TYPO_SCALE_MAX_RATIO");
366
+ return t;
367
+ }
368
+
369
+ async function main() {
370
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
371
+ const tokens = argv[0] || process.env.TYPO_TOKENS;
372
+ const config = argv[1] || process.env.TYPO_CONFIG;
373
+ if (!tokens) {
374
+ console.error("✗ typography-gate: usage: typography-gate.mjs <type-tokens.(json|css)> [config.json]");
375
+ console.error(" (or set $TYPO_TOKENS and $TYPO_CONFIG)");
376
+ process.exit(2);
377
+ }
378
+ const cfg = config ? JSON.parse(await readFile(config, "utf8")) : {};
379
+ const report = await runTypographyGate({ tokens, config: cfg, thresholds: envThresholds() });
380
+ if (process.env.TYPO_REPORT) await writeFile(resolve(process.env.TYPO_REPORT), JSON.stringify(report, null, 2) + "\n");
381
+
382
+ const s = report.summary;
383
+ const line = `typography-gate: ${s.styles} style(s) (${s.bodyStyles} body) — ${s.errors} error(s), ${s.warnings} warning(s)`;
384
+ const fmt = (f) => ` · [${f.severity}] ${f.style}${f.sc ? ` (SC ${f.sc})` : ""}: ${f.msg}`;
385
+ if (!report.passed) {
386
+ console.error(`✗ ${line}`);
387
+ for (const f of report.findings) console.error(fmt(f));
388
+ process.exit(1);
389
+ }
390
+ console.log(`✓ ${line}`);
391
+ for (const f of report.findings) console.log(fmt(f)); // warnings only when passing
392
+ }
393
+
394
+ if (import.meta.url === `file://${process.argv[1]}`) {
395
+ main().catch((e) => { console.error("✗ typography-gate: error —", e.stack || e.message); process.exit(1); });
396
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bounded-systems/conformance-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Standalone, site-agnostic web-conformance toolkit: integrity tooling + build gates + provenance generators, all parameterized so a site vendors one kit instead of duplicating scripts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,8 +23,15 @@
23
23
  "ck-html-validator-gate": "gates/html-validator-gate.mjs",
24
24
  "ck-baseline-gate": "gates/baseline-gate.mjs",
25
25
  "ck-palette-gate": "gates/palette-gate.mjs",
26
+ "ck-typography-gate": "gates/typography-gate.mjs",
27
+ "ck-target-size-gate": "gates/target-size-gate.mjs",
28
+ "ck-opacity-contrast-gate": "gates/opacity-contrast-gate.mjs",
29
+ "ck-likeness-gate": "gates/likeness-gate.mjs",
30
+ "ck-pairing-extractor": "gates/pairing-extractor.mjs",
31
+ "ck-token-a11y": "gates/token-a11y.mjs",
26
32
  "ck-jargon-gate": "gates/jargon-gate.mjs",
27
33
  "ck-readability-gate": "gates/readability-gate.mjs",
34
+ "ck-ai-readability-gate": "gates/ai-readability-gate.mjs",
28
35
  "ck-commonmark-runner": "gates/commonmark-runner.mjs",
29
36
  "ck-gen-cid": "generators/gen-cid.mjs",
30
37
  "ck-gen-identity": "generators/gen-identity.mjs",