// `dev` — the development side-car: see accessibility defects on the page you are building, // while you build it, and a dashboard of the whole project's RGAA grid. // // Two halves, deliberately separate: // • this Node server (node:http, a builtin — the zero-dependency promise holds). It receives // collected pages from the browser, writes them as snapshots, audits them, and serves the // dashboard. // • an OVERLAY injected into the app in dev. It is framework-agnostic vanilla JS: the Next // component below is a four-line wrapper around it, and the same overlay would serve Vite // or anything else. // // SECURITY. The server writes files and returns audit results, so it binds to 127.0.0.1 only // — never 0.0.0.0. It is a development tool and must not be reachable from the network. CORS // is permissive on purpose (the app runs on a different port) but the loopback bind is what // actually contains it. import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { runAudit } from "./audit.js"; import { resolveMessage } from "./messages.js"; import { packCriteriaForFinding } from "./standards/derive.js"; import { attributePages, derivePages, pageScopesFrom, pagesOf } from "./pages.js"; import { renderHtmlDocument } from "./html.js"; import { crossGridBlocks, scoreboardBlocks } from "./html-report.js"; import { applyAdjudication, buildAdjudicationWorklist, formatAdjudication } from "./adjudicate.js"; import { BATCH_SIZE, applyRawVerdicts, judgeAll } from "./llm.js"; import { readSnapshots, validateSnapshotMeta, writeSnapshot, type AxNode, type BoxDigest, type CssDigest, type StyleDigest } from "./snapshot.js"; import { CORE, type StandardId, isCore, loadPack } from "./standards/index.js"; import { SCHEMA_VERSION, VERSION, type AuditResult, type Finding, type Lang, type PageResult } from "./types.js"; import { COLLECT_SNAPSHOT } from "./snapshot.js"; export const DEV_DEFAULT_PORT = 4111; /** The criterion label to show in the terminal: the pack's own id when projected. */ function criterionLabel(f: Finding, standard: StandardId): string { if (isCore(standard)) return `WCAG ${f.criteriaId}`; const pack = loadPack(standard); const ids = packCriteriaForFinding(pack, f); return ids.length ? `${pack.name} ${ids.join(", ")}` : `WCAG ${f.criteriaId}`; } // ---- the browser overlay ------------------------------------------------------------------ // Injected into the app in DEV ONLY. It renders inside a shadow root so the app's CSS cannot // reach it and its own CSS cannot leak. // // The subtle part: the overlay is DOM, and the collector serializes DOM. Left in place it // would be captured, audited, and would report non-conformities about ITSELF — and, worse, // its element would shift every document-order index by one, breaking the join between the // styles digest and the DOM. So the host is REMOVED from the document for the duration of the // collection and re-attached immediately after. That keeps `querySelectorAll` and // `documentElement.outerHTML` in perfect agreement, which is what the whole signal join rests // on. export function overlayJs(): string { return `(() => { const ENDPOINT = window.__ULTRA11Y_ENDPOINT__ || "http://127.0.0.1:${DEV_DEFAULT_PORT}"; const HOST_ID = "__ultra11y_overlay"; if (document.getElementById(HOST_ID)) return; const host = document.createElement("div"); host.id = HOST_ID; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = \` :host { all: initial; } .bar { position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; } button.fab { border: 0; border-radius: 999px; padding: 10px 14px; cursor: pointer; background: #1c1c1e; color: #fff; box-shadow: 0 2px 12px rgba(0,0,0,.35); } button.fab[data-state="ok"] { background: #0a7d33; } button.fab[data-state="bad"] { background: #b3261e; } .panel { position: fixed; right: 16px; bottom: 64px; width: min(460px, calc(100vw - 32px)); max-height: min(60vh, 560px); overflow: auto; background: #fff; color: #111; border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,.28); padding: 12px 14px; } @media (prefers-color-scheme: dark) { .panel { background: #1c1c1e; color: #f2f2f7; } } h2 { font-size: 13px; margin: 0 0 8px; } ul { list-style: none; margin: 0; padding: 0; } li { padding: 7px 0; border-top: 1px solid rgba(127,127,127,.25); } .sev { font-weight: 700; margin-right: 6px; } .b { color: #b3261e; } .m { color: #b06000; } .n { color: #666; } a { color: inherit; } code { font: 11px/1.4 ui-monospace, monospace; opacity: .8; } .empty { opacity: .75; } \`; const root = document.createElement("div"); root.className = "bar"; const fab = document.createElement("button"); fab.className = "fab"; fab.type = "button"; fab.textContent = "a11y …"; const panel = document.createElement("div"); panel.className = "panel"; panel.hidden = true; root.append(fab, panel); shadow.append(style, root); document.body.appendChild(host); fab.addEventListener("click", () => { panel.hidden = !panel.hidden; }); const SEV = { bloquant: ["b", "BLOQUANT"], majeur: ["m", "MAJEUR"], mineur: ["n", "mineur"] }; function render(findings, error) { if (error) { fab.textContent = "a11y ?"; fab.dataset.state = ""; panel.innerHTML = "

ultra11y

" + String(error) + "

"; return; } const nc = findings.filter((f) => !f.advisory); fab.textContent = nc.length ? "a11y " + nc.length : "a11y ✓"; fab.dataset.state = nc.length ? "bad" : "ok"; if (!findings.length) { panel.innerHTML = "

ultra11y

No non-conformity detected on this page by the static engine. The judgment criteria remain yours.

"; return; } const h = document.createElement("h2"); h.textContent = "ultra11y — " + nc.length + " non-conformity(ies) on this page"; const ul = document.createElement("ul"); for (const f of findings.slice(0, 60)) { const li = document.createElement("li"); const s = SEV[f.severity] || ["n", f.severity]; const sev = document.createElement("span"); sev.className = "sev " + s[0]; sev.textContent = f.advisory ? "reco" : s[1]; li.append(sev, document.createTextNode(f.message)); const where = (f.origin && f.origin.sourceFile) || f.file; const line = (f.origin && f.origin.sourceLine) || f.line || 1; if (where && !/^https?:/.test(where)) { const a = document.createElement("a"); // Next exposes this endpoint in dev; it opens the file in the configured editor. a.href = "/__nextjs_launch-editor?file=" + encodeURIComponent(where + ":" + line + ":1"); a.textContent = where + ":" + line; a.addEventListener("click", (e) => { e.preventDefault(); fetch(a.href).catch(() => {}); }); const code = document.createElement("code"); code.append(document.createElement("br"), a); li.append(code); } ul.append(li); } panel.replaceChildren(h, ul); } let busy = false; async function check() { if (busy) return; busy = true; fab.textContent = "a11y …"; // Detach the overlay so it is neither serialized nor counted: its presence would shift // every document-order index by one and break the styles/DOM join. const parent = host.parentNode; if (parent) parent.removeChild(host); let collected; try { collected = (0, eval)(${JSON.stringify(COLLECT_SNAPSHOT)}); } finally { if (parent) parent.appendChild(host); busy = false; } try { const res = await fetch(ENDPOINT + "/snapshot", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ meta: { v: 1, id: slug(location.pathname), name: document.title || location.pathname, url: location.href, runner: "dev" }, dom: collected.dom, styles: collected.styles, boxes: collected.boxes, css: collected.css, }), }); const json = await res.json(); render(json.findings || [], json.error); } catch (e) { render([], "ultra11y dev server unreachable at " + ENDPOINT + " — run \`ultra11y dev\`."); } } function slug(rawPath) { // location.pathname is percent-encoded for non-ASCII, exactly like new URL().pathname — // decode before folding so an accented route keeps a readable id. let path = rawPath; try { path = decodeURIComponent(rawPath); } catch (e) {} const s = path.normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return s || (path === "/" ? "accueil" : "page"); } // Re-check on client-side navigation: patch the history methods a router uses, plus back/forward. let timer; const schedule = () => { clearTimeout(timer); timer = setTimeout(check, 400); }; for (const m of ["pushState", "replaceState"]) { const orig = history[m]; history[m] = function () { const r = orig.apply(this, arguments); schedule(); return r; }; } addEventListener("popstate", schedule); schedule(); })();`; } /** The Next component written by `dev --next`. A four-line wrapper: dev-gated, client-side, * and it renders nothing. Deliberately a component the user imports rather than a bundler * hack — that keeps it working across Next versions and under Turbopack, where custom * webpack configuration does not exist. */ export function nextOverlayComponent(port: number): string { return `"use client"; // ultra11y dev overlay for Next.js. Generated by \`ultra11y dev --next\`. // // // app/layout.tsx // import { Ultra11yOverlay } from "../.ultra11y/next/overlay"; // … // {children} // // Renders NOTHING in production: the whole component short-circuits on NODE_ENV, so shipping // it is inert. Start the side-car with \`ultra11y dev\` for it to have anything to talk to. import { useEffect } from "react"; export function Ultra11yOverlay({ endpoint = "http://127.0.0.1:${port}" }) { useEffect(() => { if (process.env.NODE_ENV !== "development") return; if (document.getElementById("__ultra11y_overlay_script")) return; window.__ULTRA11Y_ENDPOINT__ = endpoint; const s = document.createElement("script"); s.id = "__ultra11y_overlay_script"; s.src = endpoint + "/overlay.js"; document.body.appendChild(s); }, [endpoint]); return null; } export default Ultra11yOverlay; `; } // ---- the dashboard ------------------------------------------------------------------------- /** The per-page grid as a self-contained HTML page. * * It used to carry its own ``, its own escaper, its own stylesheet and its own copy of * the grid loop — four things to keep in step with the report, and all four had drifted. It * now builds the SAME `Doc` the HTML report builds and hands it to the same renderer, so the * side-car inherits the landmarks, the print sheet and the AA-corrected dark palette for * free, and the dashboard cannot disagree with the report about a page's status. */ export function dashboardHtml(result: AuditResult | null, pages: PageResult[], standard: StandardId, lang: Lang): string { const fr = lang === "fr"; const title = `ultra11y — ${isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name}`; if (!result || !pages.length) { const empty = fr ? "Aucune page capturée pour l'instant. Ouvrez votre application avec l'overlay actif : chaque page visitée apparaîtra ici." : "No page captured yet. Open your app with the overlay active: every page you visit shows up here."; return renderHtmlDocument({ lang, title, blocks: [{ kind: "para", runs: [{ text: empty }] }] }); } return renderHtmlDocument({ lang, title, subtitle: [{ text: `${pages.length} ${fr ? "page(s) capturée(s)" : "page(s) captured"} · ` }, { text: result.date, mono: true }], blocks: [...scoreboardBlocks(result, standard, lang), ...crossGridBlocks(result, pages, standard, lang)], }); } // ---- the server ----------------------------------------------------------------------------- export interface DevOptions { root: string; port: number; standard: StandardId; lang: Lang; onLog?: (msg: string) => void; } function cors(res: ServerResponse): void { res.setHeader("access-control-allow-origin", "*"); res.setHeader("access-control-allow-headers", "content-type"); res.setHeader("access-control-allow-methods", "POST, GET, OPTIONS"); } async function readBody(req: IncomingMessage, limit = 64 * 1024 * 1024): Promise { const chunks: Buffer[] = []; let size = 0; for await (const c of req) { const buf = c as Buffer; size += buf.length; if (size > limit) throw new Error("payload too large"); chunks.push(buf); } return Buffer.concat(chunks).toString("utf8"); } /** Audit one collected page: persist it as a snapshot, then run the engine over its DOM. */ export function auditCollected( root: string, payload: { meta?: unknown; dom?: unknown; styles?: StyleDigest; boxes?: BoxDigest; axtree?: AxNode; css?: CssDigest; screenshot?: unknown }, ): { ok: true; result: AuditResult } | { ok: false; error: string } { const v = validateSnapshotMeta(payload.meta); if (!v.ok || !v.meta) return { ok: false, error: v.issues.map((i) => `${i.path}: ${i.message}`).join("; ") }; if (typeof payload.dom !== "string" || !payload.dom.trim()) return { ok: false, error: "dom is required" }; // The browser extension DOES capture a viewport screenshot and posts it here // (extension/background.js). It used to be dropped on the floor: this signature had no // `screenshot`, so `screen.png` was never written for an extension-collected page and the // pixel tier — the only tier that can answer contrast over a gradient or an image — silently // declined on every one of them. Typed `unknown` and checked, because a producer is // untrusted input like any other field here. const shot = typeof payload.screenshot === "string" && payload.screenshot.trim() ? payload.screenshot : undefined; const dir = writeSnapshot(root, { meta: v.meta, dom: payload.dom, ...(payload.styles ? { styles: payload.styles } : {}), ...(payload.boxes ? { boxes: payload.boxes } : {}), ...(payload.axtree ? { axtree: payload.axtree } : {}), ...(payload.css ? { css: payload.css } : {}), ...(shot ? { screenshotBase64: shot } : {}), }); return { ok: true, result: runAudit({ inputs: [join(dir, "dom.html")] }) }; } /** The whole project's per-page view, rebuilt from the snapshots on disk. * * The MEASUREMENT is always fresh — the rules re-run over every snapshot, so a page fixed a * moment ago shows as fixed. The DECISIONS are not a measurement: an adjudication of the * judgment criteria is a ruling that stays true until the page changes, so a previously * applied one is folded back on rather than thrown away. Without this the dashboard would * silently undo every `judge` run on the next page load, and the grid would disagree with * the report generated from the same directory. */ export function projectPages(root: string): { result: AuditResult | null; pages: PageResult[] } { const snaps = readSnapshots(root); if (!snaps.length) return { result: null, pages: [] }; const scope = pageScopesFrom(snaps); const result = runAudit({ inputs: [join(root, ".ultra11y/pages")] }); result.scope.pages = scope; attributePages(result, scope); foldRecordedAdjudication(root, result); return { result, pages: derivePages(result, scope) }; } /** The per-page rates the judge endpoint reports back to the dashboard. * * It goes through `attributePages` for the same reason every other consumer does: deriving pages * from a result whose findings were never attributed gives each page an empty finding list, so * every page reports as though nothing were wrong with it. The endpoint used to skip that step * and reproduced the empty-grid artefact inside the dashboard alone. */ function judgePages(result: AuditResult): { id: string; name: string; rate: number | null; decided: number; total: number }[] { const scope = pagesOf(result); attributePages(result, scope); return derivePages(result, scope).map((p) => ({ id: p.id, name: p.name, rate: p.conformancePct, decided: p.decided, total: p.total })); } /** Carry a previously applied adjudication onto a freshly measured audit. Only the agent's * own decisions move: a criterion the ENGINE decided this run keeps this run's verdict. */ function foldRecordedAdjudication(root: string, fresh: AuditResult): void { let prior: AuditResult; try { prior = JSON.parse(readFileSync(join(root, "audits", "audit-latest.json"), "utf8")) as AuditResult; } catch { return; // no prior run — nothing to carry } if (prior.packAdjudication) fresh.packAdjudication = prior.packAdjudication; if (prior.adjudicated) fresh.adjudicated = prior.adjudicated; const byId = new Map(fresh.criteria.map((c) => [c.id, c])); for (const c of prior.criteria) { if (c.decidedBy !== "agent") continue; const target = byId.get(c.id); // A criterion the fresh run decided by itself is authoritative: the page moved, and a // stale ruling must never override a live measurement. if (target?.status !== "manual") continue; target.status = c.status; target.decidedBy = "agent"; if (c.justification) target.justification = c.justification; } fresh.residualRisks = fresh.residualRisks.filter((r) => byId.get(r.criteriaId)?.status === "manual"); } export interface DevServer { port: number; close(): Promise; } /** Start the side-car. Binds to LOOPBACK ONLY — it writes files and returns audits, so it must * never be reachable from the network. */ export function startDevServer(opts: DevOptions): Promise { const log = opts.onLog ?? (() => {}); const server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://127.0.0.1"); cors(res); if (req.method === "OPTIONS") { res.writeHead(204).end(); return; } if (url.pathname === "/overlay.js") { res.writeHead(200, { "content-type": "application/javascript; charset=utf-8", "cache-control": "no-store" }).end(overlayJs()); return; } // The browser extension asks for the collector rather than shipping a copy — same reason // the overlay is served rather than pasted. One collector, one format, no drift. if (url.pathname === "/collector.js") { res .writeHead(200, { "content-type": "application/javascript; charset=utf-8", "cache-control": "no-store" }) .end(`window.__ULTRA11Y_COLLECT__ = ${JSON.stringify(COLLECT_SNAPSHOT)};`); return; } // A liveness probe, so a client can say "run `ultra11y dev`" instead of failing silently. if (url.pathname === "/health") { res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end( JSON.stringify({ ok: true, tool: "ultra11y", version: VERSION, port: opts.port, standard: opts.standard, lang: opts.lang, pages: readSnapshots(opts.root).length, }), ); return; } if (url.pathname === "/snapshot" && req.method === "POST") { void (async () => { try { const body = await readBody(req); const payload = JSON.parse(body) as Parameters[1]; const r = auditCollected(opts.root, payload); if (!r.ok) { res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: r.error })); return; } const nc = r.result.findings.filter((f) => !f.advisory); const name = (payload.meta as { name?: string } | undefined)?.name ?? "page"; log(opts.lang === "fr" ? `ultra11y dev : ${name} — ${nc.length} non-conformité(s)` : `ultra11y dev: ${name} — ${nc.length} non-conformity(ies)`); // A readable terminal line, NOT the workflow-command form: that one is // percent-escaped for GitHub's parser and reads as noise in a shell. for (const f of nc.slice(0, 20)) { const where = f.origin?.sourceFile ?? f.file; log( ` ${f.severity} ${criterionLabel(f, opts.standard)} · ${where}:${Math.max(1, f.origin?.sourceLine ?? f.line)} — ${resolveMessage(f, opts.lang)}`, ); } res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ findings: r.result.findings })); } catch (e) { res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) })); } })(); return; } // Adjudicate the judgment criteria of the pages captured so far, for a client with no // coding agent behind it (the browser extension). // // THE KEY. It arrives per request in `x-anthropic-key`, is used for the call, and is // never written to disk, never logged, never echoed back. A server-side // ANTHROPIC_API_KEY is the fallback for someone who would rather keep it out of the // browser entirely. With neither, the endpoint says so — it never quietly does nothing. if (url.pathname === "/judge" && req.method === "POST") { void (async () => { const key = (req.headers["x-anthropic-key"] as string | undefined)?.trim() || process.env.ANTHROPIC_API_KEY?.trim(); if (!key) { res.writeHead(400, { "content-type": "application/json" }).end( JSON.stringify({ error: "No API key. Set it in the extension's options, or start `ultra11y dev` with ANTHROPIC_API_KEY in its environment. This is the only part of ultra11y that takes one.", }), ); return; } try { const { result } = projectPages(opts.root); if (!result) { res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: "No page captured yet — audit a page first." })); return; } const items = buildAdjudicationWorklist(result, { standard: opts.standard }); if (!items.length) { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ adjudicated: 0, remaining: 0, applied: false })); return; } const batches: { items: typeof items; prompt: string }[] = []; for (let i = 0; i < items.length; i += BATCH_SIZE) { const slice = items.slice(i, i + BATCH_SIZE); batches.push({ items: slice, prompt: formatAdjudication(slice, opts.lang, opts.standard) }); } log(opts.lang === "fr" ? `ultra11y dev : adjudication de ${items.length} critère(s)…` : `ultra11y dev: adjudicating ${items.length} criterion(ia)…`); const { verdicts, failures } = await judgeAll(batches, { apiKey: key }); applyRawVerdicts(items, verdicts); // The SAME fail-closed gate an agent's verdicts pass. A model cannot assert a // conformance here that it could not assert on the CLI. const applied = applyAdjudication(result, { tool: "ultra11y", kind: "adjudication", schemaVersion: SCHEMA_VERSION, standard: opts.standard, auditDate: result.date, items, }); // A gate-refused adjudication changes nothing on disk — that is the point of the // gate. An ACCEPTED one is persisted, or the button would appear to work while // leaving the dashboard, the report and the per-page grid exactly as they were. let auditPath: string | undefined; if (applied.ok) { auditPath = join(opts.root, "audits", "audit-latest.json"); mkdirSync(dirname(auditPath), { recursive: true }); writeFileSync(auditPath, `${JSON.stringify(applied.audit, null, 2)}\n`); } res.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ adjudicated: verdicts.length, total: items.length, applied: applied.ok, ...(auditPath ? { auditPath } : {}), issues: applied.ok ? [] : applied.issues.slice(0, 20), failures, pages: applied.ok ? judgePages(applied.audit) : [], }), ); } catch (e) { res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) })); } })(); return; } if (url.pathname === "/" || url.pathname === "/index.html") { const { result, pages } = projectPages(opts.root); res .writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }) .end(dashboardHtml(result, pages, opts.standard, opts.lang)); return; } res.writeHead(404, { "content-type": "text/plain" }).end("not found"); }); return new Promise((resolve, reject) => { server.once("error", reject); // 127.0.0.1, never 0.0.0.0 — see the security note at the top of this file. server.listen(opts.port, "127.0.0.1", () => { resolve({ port: (server.address() as { port: number }).port, close: () => new Promise((r) => server.close(() => r())), }); }); }); } export { CORE };