@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.
- 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 +267 -0
- package/gates/target-size-gate.mjs +166 -0
- package/gates/token-a11y.mjs +128 -0
- package/gates/typography-gate.mjs +396 -0
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ hardcodes `robertdelanghe.dev`, `bounded.tools`, an account, or an email.
|
|
|
13
13
|
```
|
|
14
14
|
integrity/ verify-site · verify (sigstore) · gen-sitemanifest · gen-provenance · structure-audit · http-probe
|
|
15
15
|
gates/ sbom (gen + completeness) · shacl-runner · seo-gate · axe-gate (axe-core a11y) · vuln-gate (npm audit) · html-validator-gate (vnu) · baseline-gate (web-features) · jargon-gate (plain-language) · readability-gate · commonmark-runner · semantic (lone)
|
|
16
|
+
gates/ Token Accessibility suite (static token-level a11y → TOKEN-A11Y.md): palette · pairing-extractor · typography · target-size · opacity-contrast · likeness · token-a11y (unified runner)
|
|
16
17
|
gates/conformance/ conformance-report — lone's conformance() projection (Node port of jsr:@bounded-systems/lone@0.4) + a generic HTML renderer
|
|
17
18
|
generators/ gen-cid (IPFS UnixFS) · gen-identity (did:web + VC) · gen-snapshots (reader/markdown) · gen-print-snapshots (PDF) · openapi (static-API helper core)
|
|
18
19
|
emitters/ reprDigest (RFC 9530) · securityTxt (RFC 9116) · webManifest · markdown-sibling headers
|
|
@@ -79,7 +80,14 @@ in-process verifier). The Deno semantic runner pins its imports in
|
|
|
79
80
|
| `baseline-gate.mjs` | `node …/baseline-gate.mjs [cssGlob]` | `$BASELINE_CSS` (default `dist/**/*.css`). Optional `$BASELINE_TARGET` (`widely`/`newly`, default `widely`), `$BASELINE_REPORT`. Maps the shipped CSS to **web-features Baseline** data (via `stylelint-plugin-use-baseline` — headless, no browser) and **fails closed** when the site-wide status is below target. A feature behind an `@supports` query is a tested fallback and doesn't count against it. The report's `baseline: { status, fallbackTested }` envelope is what `conformance-report`'s `compatibility.baseline` criterion consumes. |
|
|
80
81
|
| `palette-gate.mjs` | `node …/palette-gate.mjs <tokens.(json\|css)> <pairings.json>` | **Two inputs the consumer supplies**: a token map (a DTCG `tokens.json` — primitive→semantic aliases resolved — or a `tokens.css` of `--name: #hex` custom properties) and a `pairings.json` declaring the fg/bg pairs that actually co-occur (`{ "pairings":[{fg,bg,kind,size?,weight?,name?}], "categorical":[…], "thresholds":{…} }`; `kind` ∈ `text`\|`large-text`\|`ui`, `fg`/`bg` are token names or literal `#hex`). Runs **static colour-palette analysis** — zero-dep, every primitive computed by hand: (1) **CVD-safe contrast** — simulates each colour under deuteranopia/protanopia/tritanopia (**Machado-2009** matrices), recomputes the WCAG ratio per pair under each, and flags any pair dropping below AA, plus **categorical collapse** (CIEDE2000 ΔE below `$PALETTE_COLLAPSE_DELTAE`, default 10) post-transform; (2) **APCA** — implements **APCA-W3 ~0.1.9**, reports `Lc` per text pair against a font-size/weight-aware (or baseline `$PALETTE_MIN_LC_TEXT` 60 / `$PALETTE_MIN_LC_LARGE` 45) minimum, **alongside** the WCAG-2 ratio (complement, not replacement); (3) **non-text contrast** — `kind:'ui'` pairs require ≥3:1 (WCAG 2.2 **SC 1.4.11**). Thresholds are config-driven (`pairings.json` `thresholds` ⊕ `$PALETTE_MIN_RATIO_{TEXT,LARGE,UI}`) and it **fails closed** on any failure. `$PALETTE_REPORT` writes the per-pair JSON (WCAG ratio · APCA Lc · per-CVD ratios · pass/fail per check). The report's `palette: { cvdSafe, apcaBaseline, nonTextContrast }` envelope is what a future `palette.*` criterion consumes. |
|
|
81
82
|
| `jargon-gate.mjs` | `node …/jargon-gate.mjs [distDir] [--strict]` | `$JARGON_DIST`. Optional `$JARGON_ALLOWLIST` (comma list of accepted terms), `$JARGON_MIN_LENGTH` (default `3`), `$JARGON_THRESHOLD` (default `0`, for `--strict`), `$JARGON_REPORT`. Flags **undefined jargon** in the prose: words not in a 275k-word English dictionary (compounds/possessives atomized first) that the page does not **define** via `<abbr title>`, `<dfn>`, or a `<dl>` glossary — for W3C COGA / WCAG 3.1.3 Unusual Words and for AI readers. WARN-only by default; `--strict` fails closed. Report carries a `plainLanguage: { undefinedJargon, glossaryPresent }` envelope (for a future `cognitive.plain-language` criterion). |
|
|
83
|
+
| `typography-gate.mjs` | `node …/typography-gate.mjs <type-tokens.(json\|css)> [config.json]` | **Token Accessibility suite.** Type tokens (DTCG `$type:"typography"` recipes or `.bs-text-*` CSS) + a `config.json` declaring which styles are **body** (`{ "body":["body"], "thresholds":{…} }`). Static checks, each mapped to a SC: body **line-height ≥ 1.5** (1.4.12); **text-spacing achievability** — spacing/line-height in overridable relative units, never px-pinned (1.4.12); **min font-size** — body ≥ ~16px (warn) / ≥ ~12px hard floor (error) + modular-scale sanity (1.4.4); **weight×size legibility** — thin weight (≤200) at small size → error, plus a `requiredApcaLc` cross-link to the palette gate (1.4.3/1.4.8). Fails closed on any error; `$TYPO_REPORT` writes the JSON. |
|
|
84
|
+
| `target-size-gate.mjs` | `node …/target-size-gate.mjs <config.json>` | **Token Accessibility suite.** A `config.json` where the consumer **declares** which tokens are interactive targets (`{ "targets":[{name,width,height\|size,exception?,reason?}], "tokens":{…}, "thresholds":{minPx,aaaPx} }`). Enforces target **≥ 24×24px** (2.5.8 AA → error below) and reports **≥ 44×44px** (2.5.5 AAA) status; honours the 2.5.8 `inline`/`essential`/`user-agent`/`spacing` exceptions with an audit `reason`. No target tokens → `coverage:"none"` (vacuous pass + gap note). `$TARGET_REPORT` writes the JSON. |
|
|
85
|
+
| `opacity-contrast-gate.mjs` | `node …/opacity-contrast-gate.mjs <tokens.(json\|css)> <usages.json>` | **Token Accessibility suite — the cross-cutting guard.** Token map + a `usages.json` declaring "opacity applied to a foreground" usages (`{ "usages":[{fg,bg,opacity,kind,name?}], "opacityTokens":{…}, "thresholds":{…} }`; `opacity` is 0..1 or a `{token}` ref). Composites fg over bg (Porter-Duff source-over) at the stated alpha and requires the **effective** WCAG contrast ≥ floor (4.5 text / 3 large/ui — 1.4.3/1.4.11), reporting both nominal and effective ratio so the drop is visible. Translucent-over-unknown-backdrop usages are flagged for review, not passed. Catches the bounded.tools opacity regression class. `$OPACITY_REPORT` writes the JSON. |
|
|
86
|
+
| `likeness-gate.mjs` | `node …/likeness-gate.mjs <tokens.(json\|css)> [config.json]` | **Token Accessibility suite.** Two CIEDE2000 checks over the colour tokens: **near-duplicate** tokens (ΔE < ~2 ⇒ perceptually identical ⇒ consolidate candidate — warning, escalatable) and **confusable categoricals** (consumer-declared distinct sets that collapse under normal vision *or* deuteranopia/protanopia/tritanopia — error; supports 1.4.1). Config: `{ "categorical":[{name,members}], "ignore":[…], "thresholds":{dupDeltaE,collapseDeltaE,dupSeverity} }`. `$LIKENESS_REPORT` writes the JSON. |
|
|
87
|
+
| `pairing-extractor.mjs` | `node …/pairing-extractor.mjs <tokens.(json\|css)> <style1.css> [style2.css …]` | **Token Accessibility suite — coverage engine.** Derives the real fg×bg pairings from **actual stylesheet usage** (resolves `var(--token)`/literal colours; pairs by same-rule co-occurrence → ancestor-selector containment → root surface, tagged `rule`/`surface`/`root` confidence), **unions** any declared `$PAIRING_DECLARED` pairings in, scores every pair through the palette check, and emits a **pairing matrix** (WCAG · APCA Lc · per-CVD ratios) to `$PAIRING_MATRIX` (Markdown) / `$PAIRING_REPORT` (JSON). No DOM ⇒ a reviewed **superset** (over-generates safely); **report-only** unless `$PAIRING_GATE=1`. Removes the hand-maintained pairings list that let the opacity bug slip. |
|
|
88
|
+
| `token-a11y.mjs` | `node …/token-a11y.mjs <token-a11y.json>` | **Token Accessibility suite — unified runner** (`ck-token-a11y`). One `token-a11y.json` drives every member (palette · pairing · typography · targetSize · opacity · likeness) over one token map and **fails closed** if any fails. See [`TOKEN-A11Y.md`](./TOKEN-A11Y.md) for the standard. `$TOKEN_A11Y_REPORT` writes the aggregate JSON. |
|
|
82
89
|
| `readability-gate.mjs` | `node …/readability-gate.mjs <corpus.json> [--strict]` | **The corpus is an input** the site assembles from its copy: a JSON array of `{id,text}` or an `{id:text}` map. Optional `$READABILITY_THRESHOLDS`, `$READABILITY_MIN_WORDS`, `$READABILITY_KNOWN_ACRONYMS`. WARN-only unless `--strict`. |
|
|
90
|
+
| `ai-readability-gate.mjs` | `node …/ai-readability-gate.mjs [distDir]` | Re-proves lone's `semantic.ai-readability` at build time: emits `{llmsTxtPresent, linksResolve, markdownSiblings}` — checks `llms.txt` exists, its internal links resolve (and none hit `$AIR_PRIVATE` paths), and every content page has a Markdown sibling (`$AIR_SIBLING_SUFFIX`, default `.md`; `$AIR_SIBLING_IGNORE` defaults to `404`). Fail-closed (`$AIR_STRICT=0` to report only); `$AIR_REPORT` writes the evidence JSON. Static only — the `Accept: text/markdown` content-negotiation half is served-edge behaviour, probe it with `ck-http-probe`. |
|
|
83
91
|
| `commonmark-runner.mjs` | `node …/commonmark-runner.mjs <renderer.mjs> [fixtures.json]` | **The site's markdown renderer module** (export `renderMarkdown`, or set `$COMMONMARK_RENDER_EXPORT`). Default fixtures pin a safe CommonMark subset + 4 hostile-HTML escapes; a site with a different renderer supplies its own `fixtures.json`. |
|
|
84
92
|
| `semantic/gate.ts` | `deno run --allow-read --allow-net …/gate.ts` | Built HTML in `$SEMANTIC_DIR` (default `dist/blog`); `$SEMANTIC_SELECTOR` (subject node, default `article`). Imports `jsr:@bounded-systems/lone`; any error-severity finding fails CI. |
|
|
85
93
|
| `conformance-report.mjs` | `import { buildConformanceReport, renderConformanceReport } from "…/gates/conformance-report.mjs"` | **The site's evidence** — `loneFindings` (the semantic gate's DOM findings, or `null` when no DOM was blessed → those criteria report `not-assessed`) + an external-evidence envelope whose fields it gathers from its own gates (`jsonLdShacl`, `sbom`, `contentDigests`, `slsaProvenance`, …). `renderConformanceReport(report, { evidenceHref })` → a class-based HTML fragment; the consumer wraps it in its template and supplies per-criterion evidence URLs. Zero-dep; the conformance MODEL is a Node port of `jsr:@bounded-systems/lone@0.4`'s `conformance()` in `gates/conformance/`. |
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// AI-readability gate — re-proves lone's `semantic.ai-readability` criterion at build
|
|
3
|
+
// time: a site is legible to LLM agents when it ships an `llms.txt` index, that index's
|
|
4
|
+
// links actually resolve, and every page has a Markdown sibling (the clean,
|
|
5
|
+
// chrome-free source an agent reads instead of scraping rendered HTML).
|
|
6
|
+
//
|
|
7
|
+
// It emits exactly the evidence shape the standard consumes —
|
|
8
|
+
// aiReadability: { llmsTxtPresent, linksResolve, markdownSiblings }
|
|
9
|
+
// — so the consumer asserts the criterion and THIS gate proves it (fail-closed).
|
|
10
|
+
//
|
|
11
|
+
// node gates/ai-readability-gate.mjs [distDir]
|
|
12
|
+
//
|
|
13
|
+
// Static / build-time only. The HTTP half of AI-readability — `Accept: text/markdown`
|
|
14
|
+
// content negotiation (`Content-Type: text/markdown; Vary: Accept`) — is served-edge
|
|
15
|
+
// behaviour, not a build artifact; probe that at deploy with `ck-http-probe`.
|
|
16
|
+
//
|
|
17
|
+
// Config (nothing site-specific hard-coded):
|
|
18
|
+
// argv / $AIR_DIST built output dir (default: "dist")
|
|
19
|
+
// $AIR_LLMS index filename under dist (default: "llms.txt")
|
|
20
|
+
// $AIR_SIBLING_SUFFIX Markdown sibling suffix (default: ".md")
|
|
21
|
+
// $AIR_SIBLING_IGNORE comma globs of pages needing none (default: "404")
|
|
22
|
+
// $AIR_PRIVATE comma path prefixes that are private (default: none)
|
|
23
|
+
// $AIR_REPORT write the evidence JSON to this path (optional)
|
|
24
|
+
// $AIR_STRICT=0 report only, never exit non-zero (default: fail-closed)
|
|
25
|
+
import { readFile, readdir, access } from "node:fs/promises";
|
|
26
|
+
import { resolve, join, dirname, relative, extname, posix } from "node:path";
|
|
27
|
+
|
|
28
|
+
// ── Pure core (filesystem-free; unit-testable) ───────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Extract link targets from Markdown: `[text](url)` + `<url>` autolinks. */
|
|
31
|
+
export function extractMarkdownLinks(md) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const text = String(md);
|
|
34
|
+
for (const m of text.matchAll(/\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+["'][^)]*["'])?\s*\)/g)) out.push(m[1]);
|
|
35
|
+
for (const m of text.matchAll(/<((?:https?:\/\/|\/)[^>\s]+)>/g)) out.push(m[1]);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** internal (relative / root-absolute) · external (scheme or //) · anchor (#…). */
|
|
40
|
+
export function classifyLink(url) {
|
|
41
|
+
const u = String(url).trim();
|
|
42
|
+
if (u.startsWith("#")) return "anchor";
|
|
43
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(u) || u.startsWith("//")) return "external";
|
|
44
|
+
return "internal";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function stripFragment(url) {
|
|
48
|
+
return String(url).split("#")[0].split("?")[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Candidate dist files an internal link could resolve to (extensionless → .html / dir index). */
|
|
52
|
+
export function resolveCandidates(url, fromDir) {
|
|
53
|
+
const clean = stripFragment(url);
|
|
54
|
+
const base = clean.startsWith("/") ? clean.slice(1) : posix.join(fromDir, clean);
|
|
55
|
+
const cands = [base];
|
|
56
|
+
if (base.endsWith("/")) cands.push(posix.join(base, "index.html"));
|
|
57
|
+
else if (!extname(base)) cands.push(base + ".html", posix.join(base, "index.html"));
|
|
58
|
+
return cands.map((c) => c.replace(/^\/+/, ""));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Markdown sibling for an HTML page: dist/blog/x.html → dist/blog/x<suffix>. */
|
|
62
|
+
export function siblingFor(htmlRel, suffix = ".md") {
|
|
63
|
+
return htmlRel.replace(/\.html$/, suffix);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isPrivate(url, prefixes = []) {
|
|
67
|
+
const p = stripFragment(url);
|
|
68
|
+
return prefixes.some((pre) => pre && (p === pre || p.startsWith(pre.endsWith("/") ? pre : pre + "/") || p.startsWith(pre)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function matchesAny(rel, globs = []) {
|
|
72
|
+
return globs.some((g) => {
|
|
73
|
+
const re = new RegExp("^" + g.trim().replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "(\\.html)?$");
|
|
74
|
+
return re.test(rel) || re.test(rel.replace(/\.html$/, ""));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Impure: read dist + evaluate ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
|
|
81
|
+
|
|
82
|
+
async function walkHtml(dir, root = dir) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
85
|
+
const p = join(dir, e.name);
|
|
86
|
+
if (e.isDirectory()) out.push(...await walkHtml(p, root));
|
|
87
|
+
else if (e.name.endsWith(".html")) out.push(relative(root, p));
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function evaluateAiReadability({
|
|
93
|
+
dist, llmsName = "llms.txt", siblingSuffix = ".md", siblingIgnore = ["404"], privatePrefixes = [],
|
|
94
|
+
}) {
|
|
95
|
+
const distAbs = resolve(dist);
|
|
96
|
+
const llmsPath = join(distAbs, llmsName);
|
|
97
|
+
|
|
98
|
+
// 1. llms.txt present?
|
|
99
|
+
const llmsTxtPresent = await exists(llmsPath);
|
|
100
|
+
|
|
101
|
+
// 2. its internal links resolve to real files (and none point to private paths).
|
|
102
|
+
const brokenLinks = [], privateLinks = [];
|
|
103
|
+
if (llmsTxtPresent) {
|
|
104
|
+
const md = await readFile(llmsPath, "utf8");
|
|
105
|
+
const fromDir = dirname(relative(distAbs, llmsPath));
|
|
106
|
+
for (const url of extractMarkdownLinks(md)) {
|
|
107
|
+
if (classifyLink(url) !== "internal") continue;
|
|
108
|
+
if (isPrivate(url, privatePrefixes)) { privateLinks.push(url); continue; }
|
|
109
|
+
const cands = resolveCandidates(url, fromDir === "." ? "" : fromDir);
|
|
110
|
+
let ok = false;
|
|
111
|
+
for (const c of cands) if (await exists(join(distAbs, c))) { ok = true; break; }
|
|
112
|
+
if (!ok) brokenLinks.push(url);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const linksResolve = llmsTxtPresent && brokenLinks.length === 0 && privateLinks.length === 0;
|
|
116
|
+
|
|
117
|
+
// 3. every content page has a Markdown sibling.
|
|
118
|
+
const pages = (await walkHtml(distAbs)).sort();
|
|
119
|
+
const missingSiblings = [];
|
|
120
|
+
for (const rel of pages) {
|
|
121
|
+
if (matchesAny(rel, siblingIgnore)) continue;
|
|
122
|
+
if (!(await exists(join(distAbs, siblingFor(rel, siblingSuffix))))) missingSiblings.push(rel);
|
|
123
|
+
}
|
|
124
|
+
const markdownSiblings = pages.length > 0 && missingSiblings.length === 0;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
aiReadability: { llmsTxtPresent, linksResolve, markdownSiblings },
|
|
128
|
+
details: { llmsPath: relative(distAbs, llmsPath), brokenLinks, privateLinks, missingSiblings, pages: pages.length, siblingSuffix },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async function main() {
|
|
135
|
+
const distArg = process.argv.slice(2).find((a) => !a.startsWith("--"));
|
|
136
|
+
const dist = resolve(distArg || process.env.AIR_DIST || "dist");
|
|
137
|
+
if (!(await exists(dist))) { console.error(`✗ ai-readability-gate: ${dist} not found — build first.`); process.exit(2); }
|
|
138
|
+
|
|
139
|
+
const res = await evaluateAiReadability({
|
|
140
|
+
dist,
|
|
141
|
+
llmsName: process.env.AIR_LLMS || "llms.txt",
|
|
142
|
+
siblingSuffix: process.env.AIR_SIBLING_SUFFIX || ".md",
|
|
143
|
+
siblingIgnore: (process.env.AIR_SIBLING_IGNORE ?? "404").split(",").map((s) => s.trim()).filter(Boolean),
|
|
144
|
+
privatePrefixes: (process.env.AIR_PRIVATE || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
145
|
+
});
|
|
146
|
+
const { llmsTxtPresent, linksResolve, markdownSiblings } = res.aiReadability;
|
|
147
|
+
const d = res.details;
|
|
148
|
+
|
|
149
|
+
if (process.env.AIR_REPORT) {
|
|
150
|
+
const { writeFile } = await import("node:fs/promises");
|
|
151
|
+
await writeFile(resolve(process.env.AIR_REPORT), JSON.stringify(res.aiReadability, null, 2) + "\n");
|
|
152
|
+
}
|
|
153
|
+
if (process.argv.includes("--json")) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
154
|
+
|
|
155
|
+
console.log(` ${llmsTxtPresent ? "✓" : "✗"} llms.txt present (${d.llmsPath})`);
|
|
156
|
+
console.log(` ${linksResolve ? "✓" : "✗"} llms.txt links resolve` +
|
|
157
|
+
(d.brokenLinks.length ? ` — broken: ${d.brokenLinks.slice(0, 5).join(", ")}` : "") +
|
|
158
|
+
(d.privateLinks.length ? ` — private: ${d.privateLinks.slice(0, 5).join(", ")}` : ""));
|
|
159
|
+
console.log(` ${markdownSiblings ? "✓" : "✗"} Markdown siblings (${d.siblingSuffix}) for ${d.pages} page(s)` +
|
|
160
|
+
(d.missingSiblings.length ? ` — missing: ${d.missingSiblings.slice(0, 5).join(", ")}` : ""));
|
|
161
|
+
|
|
162
|
+
const pass = llmsTxtPresent && linksResolve && markdownSiblings;
|
|
163
|
+
console.log(`${pass ? "✓" : "✗"} ai-readability-gate: ${pass ? "AI-readable" : "NOT AI-readable"} (semantic.ai-readability)`);
|
|
164
|
+
if (!pass && process.env.AIR_STRICT !== "0") process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
168
|
+
main().catch((e) => { console.error("✗ ai-readability-gate: error —", e.stack || e.message); process.exit(1); });
|
|
169
|
+
}
|
|
@@ -79,6 +79,53 @@ const EXTERNAL_EVALUATORS = {
|
|
|
79
79
|
? met(`selected AAA met (${v.criteria.length} criteria)`)
|
|
80
80
|
: unmet("selected AAA not met");
|
|
81
81
|
},
|
|
82
|
+
"design.palette-contrast": (e) => {
|
|
83
|
+
const v = e.palette;
|
|
84
|
+
if (!v) return notAssessed("no palette-token report supplied");
|
|
85
|
+
const gaps = [];
|
|
86
|
+
if (!v.cvdSafe) gaps.push("CVD-unsafe pairings");
|
|
87
|
+
if (!v.apcaBaseline) gaps.push("below APCA baseline");
|
|
88
|
+
if (!v.nonTextContrast) gaps.push("non-text contrast fails");
|
|
89
|
+
return gaps.length === 0
|
|
90
|
+
? met("color tokens CVD-safe, APCA baseline, non-text contrast ok")
|
|
91
|
+
: unmet(gaps.join(", "));
|
|
92
|
+
},
|
|
93
|
+
"design.typography": (e) => {
|
|
94
|
+
const v = e.typography;
|
|
95
|
+
if (!v) return notAssessed("no typography-token report supplied");
|
|
96
|
+
const gaps = [];
|
|
97
|
+
if (!v.bodyLineHeight) gaps.push("body line-height < 1.5");
|
|
98
|
+
if (!v.textSpacingAchievable) gaps.push("text spacing not achievable");
|
|
99
|
+
if (!v.minFontSize) gaps.push("font below minimum");
|
|
100
|
+
if (!v.weightLegibility) gaps.push("thin weight illegible at size");
|
|
101
|
+
return gaps.length === 0
|
|
102
|
+
? met("type tokens meet line-height, spacing, size, and weight bars")
|
|
103
|
+
: unmet(gaps.join(", "));
|
|
104
|
+
},
|
|
105
|
+
"design.target-size": (e) => {
|
|
106
|
+
const v = e.targetSize;
|
|
107
|
+
if (!v) return notAssessed("no target-size report supplied");
|
|
108
|
+
return v.minSizeAA
|
|
109
|
+
? met("interactive tokens meet SC 2.5.8 AA target size")
|
|
110
|
+
: unmet("interactive tokens below SC 2.5.8 AA target size");
|
|
111
|
+
},
|
|
112
|
+
"design.opacity-contrast": (e) => {
|
|
113
|
+
const v = e.opacityContrast;
|
|
114
|
+
if (!v) return notAssessed("no opacity-contrast report supplied");
|
|
115
|
+
return v.effectiveContrast
|
|
116
|
+
? met("effective contrast holds with token opacity composited")
|
|
117
|
+
: unmet("effective contrast fails once token opacity is composited");
|
|
118
|
+
},
|
|
119
|
+
"design.token-likeness": (e) => {
|
|
120
|
+
const v = e.tokenLikeness;
|
|
121
|
+
if (!v) return notAssessed("no token-likeness report supplied");
|
|
122
|
+
const gaps = [];
|
|
123
|
+
if (!v.distinctCategoricals) gaps.push("categorical tokens not distinct");
|
|
124
|
+
if (!v.noRedundantTokens) gaps.push("redundant near-duplicate tokens");
|
|
125
|
+
return gaps.length === 0
|
|
126
|
+
? met("categorical tokens distinct; no redundant tokens")
|
|
127
|
+
: unmet(gaps.join(", "));
|
|
128
|
+
},
|
|
82
129
|
"security.asvs": (e) => {
|
|
83
130
|
const v = e.asvs;
|
|
84
131
|
if (!v || v.achievedLevel == null) return notAssessed("no OWASP ASVS attestation supplied");
|
|
@@ -132,6 +132,68 @@ export const CRITERIA = [
|
|
|
132
132
|
required: false,
|
|
133
133
|
},
|
|
134
134
|
|
|
135
|
+
// ── Design tokens — WCAG over the design system, at the source ────────────
|
|
136
|
+
// Proven over the token set BEFORE pages compose it (recommended, tier-2).
|
|
137
|
+
{
|
|
138
|
+
id: "design.palette-contrast",
|
|
139
|
+
area: "design",
|
|
140
|
+
label: "Palette contrast (design tokens)",
|
|
141
|
+
standard: "WCAG 2.2 + APCA",
|
|
142
|
+
target:
|
|
143
|
+
"Color-token pairings are CVD-safe, meet an APCA Lc baseline, and satisfy non-text contrast.",
|
|
144
|
+
level: "AA (token-level)",
|
|
145
|
+
evidence: "external",
|
|
146
|
+
required: false,
|
|
147
|
+
tier: 2,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "design.typography",
|
|
151
|
+
area: "design",
|
|
152
|
+
label: "Typography tokens",
|
|
153
|
+
standard: "WCAG 2.2",
|
|
154
|
+
target:
|
|
155
|
+
"Type tokens give body line-height ≥ 1.5, achievable text spacing (1.4.12), a minimum font size, and legible weights.",
|
|
156
|
+
level: "AA (token-level)",
|
|
157
|
+
evidence: "external",
|
|
158
|
+
required: false,
|
|
159
|
+
tier: 2,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "design.target-size",
|
|
163
|
+
area: "design",
|
|
164
|
+
label: "Target size (interactive tokens)",
|
|
165
|
+
standard: "WCAG 2.2",
|
|
166
|
+
target: "Interactive size tokens meet the SC 2.5.8 minimum target size (AA).",
|
|
167
|
+
level: "AA (token-level)",
|
|
168
|
+
evidence: "external",
|
|
169
|
+
required: false,
|
|
170
|
+
tier: 2,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "design.opacity-contrast",
|
|
174
|
+
area: "design",
|
|
175
|
+
label: "Effective contrast under opacity",
|
|
176
|
+
standard: "WCAG 2.2",
|
|
177
|
+
target:
|
|
178
|
+
"Token opacity composited over its backdrop still meets SC 1.4.3/1.4.11 contrast.",
|
|
179
|
+
level: "AA (token-level)",
|
|
180
|
+
evidence: "external",
|
|
181
|
+
required: false,
|
|
182
|
+
tier: 2,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "design.token-likeness",
|
|
186
|
+
area: "design",
|
|
187
|
+
label: "Token likeness hygiene",
|
|
188
|
+
standard: "Design-system hygiene",
|
|
189
|
+
target:
|
|
190
|
+
"Categorical tokens are perceptibly distinct and no near-duplicate (redundant) tokens collapse the system.",
|
|
191
|
+
level: "recommended",
|
|
192
|
+
evidence: "external",
|
|
193
|
+
required: false,
|
|
194
|
+
tier: 2,
|
|
195
|
+
},
|
|
196
|
+
|
|
135
197
|
// ── Security — OWASP ASVS 5.0.0 ──────────────────────────────────────────
|
|
136
198
|
{
|
|
137
199
|
id: "security.asvs",
|
|
@@ -551,6 +613,24 @@ const ENVELOPE = {
|
|
|
551
613
|
withCognitiveDisabilities: req(vBool),
|
|
552
614
|
criticalTasksPassed: req(vBool),
|
|
553
615
|
})),
|
|
616
|
+
// design-token accessibility
|
|
617
|
+
palette: opt(vObject({
|
|
618
|
+
cvdSafe: req(vBool),
|
|
619
|
+
apcaBaseline: req(vBool),
|
|
620
|
+
nonTextContrast: req(vBool),
|
|
621
|
+
})),
|
|
622
|
+
typography: opt(vObject({
|
|
623
|
+
bodyLineHeight: req(vBool),
|
|
624
|
+
textSpacingAchievable: req(vBool),
|
|
625
|
+
minFontSize: req(vBool),
|
|
626
|
+
weightLegibility: req(vBool),
|
|
627
|
+
})),
|
|
628
|
+
targetSize: opt(vObject({ minSizeAA: req(vBool) })),
|
|
629
|
+
opacityContrast: opt(vObject({ effectiveContrast: req(vBool) })),
|
|
630
|
+
tokenLikeness: opt(vObject({
|
|
631
|
+
distinctCategoricals: req(vBool),
|
|
632
|
+
noRedundantTokens: req(vBool),
|
|
633
|
+
})),
|
|
554
634
|
};
|
|
555
635
|
|
|
556
636
|
/**
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Likeness gate — token-HYGIENE + distinctness member of the Token Accessibility
|
|
3
|
+
// suite. Two perceptual-distance checks over the colour tokens, both built on the
|
|
4
|
+
// CIEDE2000 ΔE primitive the palette gate already ships (no duplication):
|
|
5
|
+
//
|
|
6
|
+
// 1. NEAR-DUPLICATE TOKENS (hygiene) — any two DISTINCT token NAMES whose colours
|
|
7
|
+
// are within ΔE < ~2 (CIEDE2000) are perceptually identical (ΔE ≈ 2.3 is the
|
|
8
|
+
// classic "just-noticeable difference" — below it the eye can't tell them
|
|
9
|
+
// apart). They're redundant: a consolidate-candidate, and a maintenance hazard
|
|
10
|
+
// (two names that silently mean the same colour drift apart later). Reported as
|
|
11
|
+
// a WARNING (it's hygiene, not a WCAG failure) — escalate via `dupSeverity`.
|
|
12
|
+
//
|
|
13
|
+
// 2. CONFUSABLE CATEGORICALS (a11y) — colours the consumer DECLARES must stay
|
|
14
|
+
// mutually distinguishable (status colours, chart series, map keys) are checked
|
|
15
|
+
// for collapse: every pair must stay ≥ a ΔE floor under NORMAL vision AND under
|
|
16
|
+
// deuteranopia / protanopia / tritanopia (Machado-2009). A categorical pair
|
|
17
|
+
// that collapses (especially only under a CVD) is an ERROR — it fails the
|
|
18
|
+
// design's own distinctness contract for some viewers. This reuses the palette
|
|
19
|
+
// gate's `evaluateCategorical`. Maps to the spirit of SC 1.4.1 (Use of Colour):
|
|
20
|
+
// if meaning rides on colour, the colours must actually differ for everyone.
|
|
21
|
+
//
|
|
22
|
+
// HONEST SCOPE: "redundant" is a perceptual claim about the token VALUES; whether a
|
|
23
|
+
// near-duplicate is INTENTIONAL (e.g. a hover state 1 step away) is design intent the
|
|
24
|
+
// gate can't read — hence WARNING, with the ΔE surfaced so a human decides. The
|
|
25
|
+
// distinctness floor is a proxy, not a guarantee of legibility at any size.
|
|
26
|
+
//
|
|
27
|
+
// Zero-dependency; primitives imported from the palette gate. Fail-closed CLI.
|
|
28
|
+
//
|
|
29
|
+
// node gates/likeness-gate.mjs <tokens.(json|css)> [config.json]
|
|
30
|
+
//
|
|
31
|
+
// INPUTS:
|
|
32
|
+
// argv[2] / $LIKENESS_TOKENS the token map (DTCG json | tokens.css).
|
|
33
|
+
// argv[3] / $LIKENESS_CONFIG a `config.json`:
|
|
34
|
+
// { "thresholds": { "dupDeltaE": 2, "collapseDeltaE": 10, "dupSeverity":"warn" },
|
|
35
|
+
// "ignore": ["tokenA","tokenB"], // names to skip in dup scan
|
|
36
|
+
// "categorical": [ { "name":"status", "members":["enforced","partial",…] } ] }
|
|
37
|
+
// (a bare `["a","b",…]` categorical array is also accepted = one unnamed group.)
|
|
38
|
+
// $LIKENESS_REPORT path to write the machine-readable JSON report.
|
|
39
|
+
//
|
|
40
|
+
// Thresholds (config ⊕ env), fail closed on categorical collapse:
|
|
41
|
+
// $LIKENESS_DUP_DELTAE (default 2) near-duplicate ΔE ceiling
|
|
42
|
+
// $LIKENESS_COLLAPSE_DELTAE (default 10) categorical distinctness floor
|
|
43
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
44
|
+
import { resolve } from "node:path";
|
|
45
|
+
import { parseHex, toHex, rgbToLab, ciede2000, loadTokens, resolveColor, evaluateCategorical } from "./palette-gate.mjs";
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_THRESHOLDS = {
|
|
48
|
+
dupDeltaE: 2, // CIEDE2000 — below ≈ JND ⇒ perceptually identical
|
|
49
|
+
collapseDeltaE: 10, // categorical distinctness floor (normal + CVD)
|
|
50
|
+
dupSeverity: "warn", // "warn" | "error"
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scan a token map for near-duplicate colours (CIEDE2000 ΔE < dupDeltaE). Returns
|
|
57
|
+
* the unordered pairs, with ΔE and an `identical` flag (ΔE === 0, e.g. two aliases
|
|
58
|
+
* of the same primitive). Pure.
|
|
59
|
+
*/
|
|
60
|
+
export function findNearDuplicates(tokenMap, thresholds = DEFAULT_THRESHOLDS, ignore = []) {
|
|
61
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
62
|
+
const skip = new Set(ignore);
|
|
63
|
+
const entries = Object.entries(tokenMap)
|
|
64
|
+
.filter(([k]) => !skip.has(k))
|
|
65
|
+
.map(([name, hex]) => { try { return { name, rgb: parseHex(hex), hex: toHex(parseHex(hex)) }; } catch { return null; } })
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
const dups = [];
|
|
68
|
+
for (let i = 0; i < entries.length; i++) {
|
|
69
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
70
|
+
const A = entries[i], B = entries[j];
|
|
71
|
+
const dE = ciede2000(rgbToLab(A.rgb), rgbToLab(B.rgb));
|
|
72
|
+
if (dE + 1e-9 < t.dupDeltaE) {
|
|
73
|
+
dups.push({ a: A.name, b: B.name, aHex: A.hex, bHex: B.hex, deltaE: round2(dE), identical: dE === 0 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
dups.sort((x, y) => x.deltaE - y.deltaE);
|
|
78
|
+
return { threshold: t.dupDeltaE, count: dups.length, duplicates: dups };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Normalize the config's categorical groups to [{ name, members:[{ref,hex}] }]. */
|
|
82
|
+
function resolveGroups(config, map) {
|
|
83
|
+
let groups = config.categorical || [];
|
|
84
|
+
if (Array.isArray(groups) && groups.every((g) => typeof g === "string")) groups = [{ name: "categorical", members: groups }];
|
|
85
|
+
return groups.map((g) => ({
|
|
86
|
+
name: g.name || "categorical",
|
|
87
|
+
members: (g.members || []).map((ref) => ({ ref, hex: resolveColor(map, ref) })),
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whole-suite evaluation → fail-closed report. Pure given resolved inputs. */
|
|
92
|
+
export function evaluateLikeness({ tokenMap = {}, groups = [], thresholds = DEFAULT_THRESHOLDS, ignore = [] } = {}) {
|
|
93
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
94
|
+
const near = findNearDuplicates(tokenMap, t, ignore);
|
|
95
|
+
|
|
96
|
+
const categorical = groups.map((g) => {
|
|
97
|
+
const res = evaluateCategorical(g.members, { collapseDeltaE: t.collapseDeltaE });
|
|
98
|
+
return { name: g.name, members: g.members.map((m) => ({ ref: m.ref, hex: toHex(parseHex(m.hex)) })), ...res };
|
|
99
|
+
});
|
|
100
|
+
const collapseCount = categorical.reduce((n, c) => n + c.count, 0);
|
|
101
|
+
|
|
102
|
+
const dupIsError = t.dupSeverity === "error";
|
|
103
|
+
const errors = (dupIsError ? near.count : 0) + collapseCount;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
passed: errors === 0,
|
|
107
|
+
thresholds: t,
|
|
108
|
+
summary: {
|
|
109
|
+
tokens: Object.keys(tokenMap).length,
|
|
110
|
+
nearDuplicates: near.count,
|
|
111
|
+
identicalPairs: near.duplicates.filter((d) => d.identical).length,
|
|
112
|
+
categoricalGroups: categorical.length,
|
|
113
|
+
categoricalCollapses: collapseCount,
|
|
114
|
+
},
|
|
115
|
+
nearDuplicates: near,
|
|
116
|
+
categorical,
|
|
117
|
+
// Envelope a future lone `likeness.*` criterion can consume.
|
|
118
|
+
likeness: {
|
|
119
|
+
distinctCategoricals: collapseCount === 0,
|
|
120
|
+
noRedundantTokens: near.count === 0,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function runLikenessGate({ tokens, config = {}, thresholds = {} }) {
|
|
126
|
+
const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
|
|
127
|
+
const cfg = typeof config === "string" ? JSON.parse(await readFile(config, "utf8")) : config;
|
|
128
|
+
const groups = resolveGroups(cfg, map);
|
|
129
|
+
return evaluateLikeness({
|
|
130
|
+
tokenMap: map,
|
|
131
|
+
groups,
|
|
132
|
+
ignore: cfg.ignore || [],
|
|
133
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...(cfg.thresholds || {}), ...thresholds },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function envThresholds() {
|
|
138
|
+
const t = {};
|
|
139
|
+
const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
|
|
140
|
+
const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
|
|
141
|
+
set("dupDeltaE", "LIKENESS_DUP_DELTAE");
|
|
142
|
+
set("collapseDeltaE", "LIKENESS_COLLAPSE_DELTAE");
|
|
143
|
+
if (process.env.LIKENESS_DUP_SEVERITY) t.dupSeverity = process.env.LIKENESS_DUP_SEVERITY;
|
|
144
|
+
return t;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
149
|
+
const tokens = argv[0] || process.env.LIKENESS_TOKENS;
|
|
150
|
+
const config = argv[1] || process.env.LIKENESS_CONFIG;
|
|
151
|
+
if (!tokens) {
|
|
152
|
+
console.error("✗ likeness-gate: usage: likeness-gate.mjs <tokens.(json|css)> [config.json] (or set $LIKENESS_TOKENS)");
|
|
153
|
+
process.exit(2);
|
|
154
|
+
}
|
|
155
|
+
const report = await runLikenessGate({ tokens, config: config || {}, thresholds: envThresholds() });
|
|
156
|
+
if (process.env.LIKENESS_REPORT) await writeFile(resolve(process.env.LIKENESS_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
157
|
+
|
|
158
|
+
const s = report.summary;
|
|
159
|
+
const line = `likeness-gate: ${s.tokens} token(s) — ${s.nearDuplicates} near-duplicate(s) (${s.identicalPairs} identical), ${s.categoricalCollapses} categorical collapse(s)`;
|
|
160
|
+
const dupLine = (d) => ` · ${d.identical ? "identical" : "near-dup"}: ${d.a} ≈ ${d.b} (${d.aHex}/${d.bHex}) ΔE ${d.deltaE}`;
|
|
161
|
+
const colLine = (c) => ` · collapse: ${c.a} vs ${c.b} (${c.aHex}/${c.bHex}) under ${c.condition} — ΔE ${c.deltaE} < ${c.min}`;
|
|
162
|
+
if (!report.passed) {
|
|
163
|
+
console.error(`✗ ${line}`);
|
|
164
|
+
for (const c of report.categorical) for (const x of c.collapses) console.error(colLine(x));
|
|
165
|
+
if (report.thresholds.dupSeverity === "error") for (const d of report.nearDuplicates.duplicates) console.error(dupLine(d));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
console.log(`✓ ${line}`);
|
|
169
|
+
for (const d of report.nearDuplicates.duplicates) console.log(dupLine(d)); // hygiene warnings
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
173
|
+
main().catch((e) => { console.error("✗ likeness-gate: error —", e.stack || e.message); process.exit(1); });
|
|
174
|
+
}
|