// `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 };