Build a two mode WCAG checker. Run automated tests first, then drive an AI through a standards linked checklist for judgement calls. Ask the user for conformance level A, AA, or AAA. Validate against WCAG 2.2 at https://www.w3.org/TR/WCAG22/. Work against a page or a single component. Support one file interactive mode and multi file prompt mode for codex cli.
- Targets: live URLs, or static component HTML plus CSS.
- Levels: A, AA, AAA.
- Standards: WCAG 2.2 Success Criteria with normative links.
- Automated engine: Playwright plus axe-core.
- AI tasks: one per success criterion that needs human-like judgement.
- Reports: JSON and Markdown with evidence and remediation steps.
- CI friendly: scriptable entry points.
inputs
- target: URL, or local component HTML and optional CSS files
- scope: page or component
- level: A, AA, or AAA
levers
- automated engine: axe-core via Playwright for machine checkable rules
- collectors: DOM, computed styles, screenshots, keyboard tab order trace, colour samples, pointer target geometry
- checklist: filtered WCAG 2.2 success criteria for the chosen level
- AI prompts: per success criterion, linked to the normative clause
outputs
- JSON and Markdown reports per success criterion with pass, fail, needs_review, not_applicable
- Evidence and remediation snippets
- codex cli prompt files that an AI agent can load dynamically, plus a single superprompt option
wcag-ai-checker/
package.json
tsconfig.json
codex.yaml
checklists/
wcag22.yaml
src/
cli.ts
runner.ts
checklist.ts
collectors/
page.ts
component.ts
analyzers/
axe.ts
focus.ts
targets.ts
colour.ts
reporters/
json.ts
markdown.ts
prompts/
system.md
criterion.md
onefile-superprompt.md
codex-scaffold.md
scripts/
build-checklist.ts
run-codex-on-prompts.sh
examples/
component.html
styles.css
pnpm init
pnpm add -D typescript ts-node @types/node
pnpm add playwright @axe-core/playwright commander inquirer yaml jsdom kleur pixelmatch
npx playwright install{
"name": "wcag-ai-checker",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"audit:url": "ts-node src/cli.ts url",
"audit:component": "ts-node src/cli.ts component",
"report": "ts-node src/cli.ts report",
"codex:prep": "ts-node src/cli.ts prompts",
"codex:run": "bash scripts/run-codex-on-prompts.sh"
},
"dependencies": {
"@axe-core/playwright": "^4.9.1",
"commander": "^12.0.0",
"inquirer": "^9.3.0",
"jsdom": "^24.0.0",
"kleur": "^4.1.5",
"pixelmatch": "^5.3.0",
"playwright": "^1.47.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.0"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src"]
}The checklist drives level filtering, which tools run first, and which prompt template to load. Include at least the new 2.2 criteria such as 2.4.11 to 2.4.13, 2.5.7, 2.5.8, 3.2.6, 3.3.7, 3.3.8, 3.3.9. Link each item to the normative SC page.
checklists/wcag22.yaml
version: "2.2"
levels: [A, AA, AAA]
# Level inheritance rules:
# - A includes only A
# - AA includes A and AA
# - AAA includes A, AA, and AAA
criteria:
- id: "1.1.1"
name: "Non-text content"
level: "A"
automated:
axeTags: ["wcag2a", "wcag111"]
ai:
template: "prompts/criterion.md"
inputs: ["htmlFragment", "ariaTree", "imgAlts", "formLabels"]
normative: "https://www.w3.org/TR/WCAG22/#non-text-content"
- id: "1.3.1"
name: "Info and Relationships"
level: "A"
automated:
axeTags: ["wcag2a", "wcag131"]
ai:
template: "prompts/criterion.md"
inputs: ["landmarks", "headings", "lists", "tables"]
normative: "https://www.w3.org/TR/WCAG22/#info-and-relationships"
- id: "1.4.3"
name: "Contrast (Minimum)"
level: "AA"
automated:
axeTags: ["wcag21aa", "wcag143"]
analyzers: ["colour"]
thresholds:
minRatio: 4.5
ai:
template: "prompts/criterion.md"
inputs: ["contrastFindings", "screenshots"]
normative: "https://www.w3.org/TR/WCAG22/#contrast-minimum"
- id: "1.4.11"
name: "Non-text Contrast"
level: "AA"
analyzers: ["colour"]
ai:
template: "prompts/criterion.md"
inputs: ["uiComponentContrast"]
normative: "https://www.w3.org/TR/WCAG22/#non-text-contrast"
- id: "2.1.1"
name: "Keyboard"
level: "A"
automated:
axeTags: ["wcag2a", "wcag211"]
ai:
template: "prompts/criterion.md"
inputs: ["focusTrace", "keyboardTraps"]
normative: "https://www.w3.org/TR/WCAG22/#keyboard"
- id: "2.4.7"
name: "Focus Visible"
level: "AA"
automated:
axeTags: ["wcag21aa", "wcag247"]
analyzers: ["focus"]
ai:
template: "prompts/criterion.md"
inputs: ["focusTrace", "screenshots"]
normative: "https://www.w3.org/TR/WCAG22/#focus-visible"
- id: "2.4.11"
name: "Focus Appearance (Minimum)"
level: "AA"
analyzers: ["focus"]
ai:
template: "prompts/criterion.md"
inputs: ["focusIndicatorMetrics", "screenshots"]
normative: "https://www.w3.org/TR/WCAG22/#focus-appearance-minimum"
- id: "2.4.12"
name: "Focus Not Obscured (Minimum)"
level: "AA"
analyzers: ["focus"]
ai:
template: "prompts/criterion.md"
inputs: ["obscuredFocusEvents", "screenshots"]
normative: "https://www.w3.org/TR/WCAG22/#focus-not-obscured-minimum"
- id: "2.4.13"
name: "Focus Appearance (Enhanced)"
level: "AAA"
analyzers: ["focus"]
ai:
template: "prompts/criterion.md"
inputs: ["focusIndicatorMetrics", "screenshots"]
normative: "https://www.w3.org/TR/WCAG22/#focus-appearance"
- id: "2.5.7"
name: "Dragging Movements"
level: "AA"
ai:
template: "prompts/criterion.md"
inputs: ["gestures", "keyboardAlternatives"]
normative: "https://www.w3.org/TR/WCAG22/#dragging-movements"
- id: "2.5.8"
name: "Target Size (Minimum)"
level: "AA"
analyzers: ["targets"]
thresholds:
minBox: [24, 24] # CSS px
ai:
template: "prompts/criterion.md"
inputs: ["pointerTargets"]
normative: "https://www.w3.org/TR/WCAG22/#target-size-minimum"
- id: "3.2.6"
name: "Consistent Help"
level: "A"
ai:
template: "prompts/criterion.md"
inputs: ["helpEntryPoints", "navigationStructure"]
normative: "https://www.w3.org/TR/WCAG22/#consistent-help"
- id: "3.3.7"
name: "Redundant Entry"
level: "A"
ai:
template: "prompts/criterion.md"
inputs: ["forms", "reusedData"]
normative: "https://www.w3.org/TR/WCAG22/#redundant-entry"
- id: "3.3.8"
name: "Accessible Authentication (Minimum)"
level: "AA"
ai:
template: "prompts/criterion.md"
inputs: ["authFlow", "screenshots", "userJourney"]
normative: "https://www.w3.org/TR/WCAG22/#accessible-authentication-minimum"
- id: "3.3.9"
name: "Accessible Authentication (Enhanced)"
level: "AAA"
ai:
template: "prompts/criterion.md"
inputs: ["authFlow", "screenshots", "userJourney"]
normative: "https://www.w3.org/TR/WCAG22/#accessible-authentication-no-exception"import { Command } from "commander";
import inquirer from "inquirer";
import { runUrl, runComponent, preparePrompts, writeReports } from "./runner.js";
const program = new Command();
program
.name("wcag-ai")
.description("AI-assisted WCAG 2.2 checker");
program
.command("url")
.argument("<target>", "URL to audit")
.option("-l, --level <A|AA|AAA>", "conformance level")
.option("--headful", "run browser headful")
.action(async (target, opts) => {
const level = await askLevel(opts.level);
const result = await runUrl({ target, level, headful: !!opts.headful });
await preparePrompts(result);
await writeReports(result);
});
program
.command("component")
.requiredOption("--html <path>", "path to HTML file")
.option("--css <path...>", "optional CSS files")
.option("-l, --level <A|AA|AAA>", "conformance level")
.action(async (opts) => {
const level = await askLevel(opts.level);
const result = await runComponent({ html: opts.html, css: opts.css ?? [], level });
await preparePrompts(result);
await writeReports(result);
});
program
.command("prompts")
.description("regenerate AI prompt files from last run artefacts")
.action(async () => { await preparePrompts(); });
program
.command("report")
.description("emit Markdown and JSON reports from last run")
.action(async () => { await writeReports(); });
program.parse();
async function askLevel(input?: string): Promise<"A"|"AA"|"AAA"> {
const valid = ["A","AA","AAA"];
if (input && valid.includes(input)) return input as any;
const { level } = await inquirer.prompt([{ type: "list", name: "level", message: "Choose WCAG level", choices: valid }]);
return level;
}import { readFile } from "fs/promises";
import yaml from "yaml";
type Level = "A" | "AA" | "AAA";
export async function buildChecklist(level: Level) {
const raw = await readFile("checklists/wcag22.yaml", "utf8");
const data = yaml.parse(raw);
const allowed = new Set(
level === "AAA" ? ["A","AA","AAA"] : level === "AA" ? ["A","AA"] : ["A"]
);
const items = (data.criteria as any[])
.filter(c => allowed.has(c.level))
.map(c => ({
id: c.id,
name: c.name,
level: c.level,
normative: c.ai?.normative ?? c.normative,
automated: c.automated ?? {},
analyzers: c.analyzers ?? [],
thresholds: c.thresholds ?? {}
}));
return items;
}import { chromium, Browser } from "playwright";
import AxeBuilder from "@axe-core/playwright";
export async function collectFromUrl(target: string, headful = false) {
const browser: Browser = await chromium.launch({ headless: !headful });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(target, { waitUntil: "domcontentloaded" });
const axe = new AxeBuilder({ page }).withTags(["wcag2a","wcag2aa","wcag2aaa"]);
const axeResults = await axe.analyze();
const html = await page.content();
const screenshot = await page.screenshot({ fullPage: true });
const focusTrace = await traceFocusOrder(page);
const pointerTargets = await measurePointerTargets(page);
const colourFindings = await sampleColours(page);
await browser.close();
return { mode: "url", target, html, screenshot, axeResults, focusTrace, pointerTargets, colourFindings };
}
// Stubs. Implementations should collect structured evidence for AI.
async function traceFocusOrder(page: any) { return { steps: [] }; }
async function measurePointerTargets(page: any) { return { items: [] }; }
async function sampleColours(page: any) { return { pairs: [] }; }import { chromium } from "playwright";
import { readFile } from "fs/promises";
import path from "node:path";
import AxeBuilder from "@axe-core/playwright";
export async function collectFromComponent(htmlPath: string, cssPaths: string[]) {
const browser = await chromium.launch();
const page = await browser.newPage();
const html = await readFile(htmlPath, "utf8");
const styles = await Promise.all(cssPaths.map(p => readFile(p, "utf8")));
const doc = `<!doctype html><html><head>${styles.map(s => `<style>${s}</style>`).join("")}</head><body>${html}</body></html>`;
await page.setContent(doc, { waitUntil: "domcontentloaded" });
const axe = new AxeBuilder({ page }).withTags(["wcag2a","wcag2aa","wcag2aaa"]);
const axeResults = await axe.analyze();
const screenshot = await page.screenshot();
await browser.close();
return { mode: "component", target: path.basename(htmlPath), html: doc, screenshot, axeResults };
}// Optional adaptor if you want to post-process axe results.
// For now, keep as a placeholder for symmetry.
export function mapAxeToChecklist(axeResults: any) {
return axeResults;
}// Stub for 2.5.8 Target Size (Minimum).
// Implement DOM queries to find interactive elements, measure bounding boxes,
// and apply the spacing exception where target spacing is >= 24 CSS px.
export type TargetFinding = { selector: string; width: number; height: number; meetsMin: boolean; note?: string; };
export async function analyzeTargets(_html: string): Promise<{ items: TargetFinding[] }> {
return { items: [] };
}// Stubs for 1.4.3 Contrast (Minimum) and 1.4.11 Non-text Contrast.
// Implement sampling of foreground and background pairs, then compute ratios.
export type ContrastPair = { selector: string; fg: string; bg: string; ratio: number; meets: boolean; note?: string; };
export async function analyzeContrast(_html: string): Promise<{ pairs: ContrastPair[] }> {
return { pairs: [] };
}// Stubs for focus related SCs: 2.4.7, 2.4.11, 2.4.12, 2.4.13.
// Implement focus traversal recording, indicator detection, and obscuration checks.
export type FocusStep = { index: number; selector: string; visible: boolean; indicatorArea?: number; note?: string; };
export async function analyzeFocus(_html: string): Promise<{ steps: FocusStep[] }> {
return { steps: [] };
}import { writeFile, mkdir } from "fs/promises";
export async function writeJsonReport(state: any) {
await mkdir(".wcag-ai/out", { recursive: true });
await writeFile(".wcag-ai/out/report.json", JSON.stringify(state, null, 2));
}import { writeFile, mkdir } from "fs/promises";
export async function writeMarkdownReport(state: any) {
await mkdir(".wcag-ai/out", { recursive: true });
const lines: string[] = [];
lines.push(`# WCAG 2.2 audit for ${state.target ?? "component"}`);
lines.push("");
lines.push(`Level: ${state.level}`);
lines.push("");
for (const sc of state.checklist ?? []) {
lines.push(`## ${sc.id} ${sc.name} (Level ${sc.level})`);
lines.push(`Normative: ${sc.normative}`);
lines.push("");
lines.push(`Status: pending AI review`);
lines.push("");
}
await writeFile(".wcag-ai/out/report.md", lines.join("\n"), "utf8");
}import { writeFile, mkdir, readFile } from "fs/promises";
import { collectFromUrl } from "./collectors/page.js";
import { collectFromComponent } from "./collectors/component.js";
import { buildChecklist } from "./checklist.js";
import { writeJsonReport } from "./reporters/json.js";
import { writeMarkdownReport } from "./reporters/markdown.js";
type Level = "A" | "AA" | "AAA";
type RunCtx = {
target?: string;
level: Level;
html?: string;
axeResults?: any;
focusTrace?: any;
pointerTargets?: any;
colourFindings?: any;
screenshot?: Buffer;
checklist?: any[];
};
export async function runUrl(opts: { target: string; level: Level; headful?: boolean }) {
const collected = await collectFromUrl(opts.target, opts.headful);
return await enrich(collected, opts.level);
}
export async function runComponent(opts: { html: string; css: string[]; level: Level }) {
const collected = await collectFromComponent(opts.html, opts.css);
return await enrich(collected, opts.level);
}
async function enrich(collected: any, level: Level) {
const checklist = await buildChecklist(level);
return { ...collected, checklist, level };
}
export async function preparePrompts(ctx?: RunCtx) {
const state = ctx ?? JSON.parse(await readFile(".wcag-ai/last.json","utf8"));
await mkdir(".wcag-ai/prompts", { recursive: true });
const sys = await readFile("prompts/system.md","utf8");
const tpl = await readFile("prompts/criterion.md","utf8");
for (const sc of state.checklist ?? []) {
const body = tpl
.replaceAll("{{SC_ID}}", sc.id)
.replaceAll("{{SC_NAME}}", sc.name)
.replaceAll("{{LEVEL}}", sc.level)
.replaceAll("{{NORMATIVE}}", sc.normative)
.replaceAll("{{HTML}}", safe(state.html ?? ""))
.replaceAll("{{AXE}}", JSON.stringify(state.axeResults ?? {}))
.replaceAll("{{FOCUS}}", JSON.stringify(state.focusTrace ?? {}))
.replaceAll("{{TARGETS}}", JSON.stringify(state.pointerTargets ?? {}))
.replaceAll("{{COLOUR}}", JSON.stringify(state.colourFindings ?? {}));
await writeFile(`.wcag-ai/prompts/${sc.id}.md`, sys + "\n\n" + body, "utf8");
}
await mkdir(".wcag-ai", { recursive: true });
await writeFile(".wcag-ai/last.json", JSON.stringify(state, null, 2));
}
export async function writeReports(ctx?: RunCtx) {
const state = ctx ?? JSON.parse(await readFile(".wcag-ai/last.json","utf8"));
await writeJsonReport(state);
await writeMarkdownReport(state);
}
function safe(s: string) {
return s.length > 200000 ? s.slice(0, 200000) + "\n<!-- truncated -->" : s;
}You are an accessibility reviewer. Follow WCAG 2.2 strictly. Cite the exact success criterion and use the provided link. If a claim depends on judgement, explain your reasoning and point at DOM evidence with selectors or text snippets. Prefer clear checklists. Output JSON first, then a short human summary.success criterion: {{SC_ID}} {{SC_NAME}} (Level {{LEVEL}})
normative link: {{NORMATIVE}}
context
- mode: page or component
- html: embedded
- automated evidence: axe and analyser outputs, screenshots if referenced
- target: conformance at Level {{LEVEL}}
artefacts
- AXE: {{AXE}}
- FOCUS: {{FOCUS}}
- TARGETS: {{TARGETS}}
- COLOUR: {{COLOUR}}
html-under-test
```html
{{HTML}}task
- State pass, fail, needs_review, or not_applicable for {{SC_ID}}.
- List evidence with selectors and explain why it passes or fails, referencing the normative link.
- Provide concrete remediation as CSS, HTML, or ARIA changes.
- For needs_review, list exactly what a human must inspect.
output
{
"sc": "{{SC_ID}}",
"level": "{{LEVEL}}",
"status": "<pass|fail|needs_review|not_applicable>",
"evidence": [],
"remediation": [],
"notes": []
}then provide a short bullet summary.
### prompts/onefile-superprompt.md
```md
role
You are an accessibility co-pilot. Use WCAG 2.2 only.
pre-flight
Ask for:
- scope: page or component
- target: URL or HTML pasted
- level: A, AA, or AAA
build
Create a checklist of success criteria for the chosen level with links to the W3C normative text.
loop
For each success criterion:
- run the task template above
- pause after each item for confirmation to continue or to drill into remediation
finish
Summarise by principle and level with a pass rate and a list of high risk failures.
objective
Create a TypeScript CLI named wcag-ai-checker that audits web pages or component HTML against WCAG 2.2 at Level A, AA, or AAA. Use Playwright and axe-core for automated checks, then emit AI task files per success criterion with links to the W3C spec.
deliverables
1) package.json with scripts: build, audit:url, audit:component, codex:prep, codex:run.
2) tsconfig.json using ES2022 modules.
3) src/ with cli.ts, runner.ts, collectors/page.ts, collectors/component.ts, analyzers/{colour,focus,targets}.ts, reporters/{json,markdown}.ts.
4) checklists/wcag22.yaml including at least these success criteria: 1.1.1, 1.3.1, 1.4.3, 1.4.11, 2.1.1, 2.4.7, 2.4.11, 2.4.12, 2.5.7, 2.5.8, 3.2.6, 3.3.7, 3.3.8, 3.3.9.
5) prompts/system.md, prompts/criterion.md, prompts/onefile-superprompt.md, prompts/codex-scaffold.md.
6) scripts/run-codex-on-prompts.sh.
7) examples/component.html and styles.css for a demo.
acceptance criteria
- Running `pnpm audit:url https://example.com --level AA` generates `.wcag-ai/prompts/*.md`.
- axe results are embedded as JSON along with at least one screenshot.
- 2.5.8 checks compare pointer targets to 24x24 CSS px or apply the spacing exception.
- Focus related prompts include data to assess 2.4.11, 2.4.12, and 2.4.13.
- Reports produce Markdown and JSON stubs prior to AI runs.
- Every success criterion entry has the W3C normative URL.
references
- WCAG 2.2 specification and Quick Reference.codex.yaml
version: 1
tasks:
scaffold:
prompt: prompts/codex-scaffold.md
files:
- package.json
- tsconfig.json
- checklists/wcag22.yaml
- src/**/*.ts
- prompts/**/*.md
review-run:
foreach:
path: ".wcag-ai/prompts"
pattern: "*.md"
run:
promptFromFile: "${item.path}"
saveJson: ".wcag-ai/out/${item.basename}.json"
saveMarkdown: ".wcag-ai/out/${item.basename}.md"scripts/run-codex-on-prompts.sh
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .wcag-ai/out
for f in .wcag-ai/prompts/*.md; do
codex run --prompt "$f" --out ".wcag-ai/out/$(basename "$f" .md).json"
donescripts/build-checklist.ts (optional generator if you want to expand YAML programmatically)
// Example scaffold that could fetch or assemble SC metadata.
// Keep manual YAML as the source of truth for now.
console.log("Use checklists/wcag22.yaml as the source of truth.");examples/component.html
<!doctype html>
<html lang="en">
<body>
<button class="btn">Primary action</button>
<a href="#" class="link">Secondary link</a>
<img src="missing.png" alt="">
<form>
<label for="email">Email</label>
<input id="email" type="email">
</form>
</body>
</html>examples/styles.css
.btn { background: #0a84ff; color: #ffffff; padding: 6px 10px; border: none; }
.link { color: #7a7a7a; }
:focus { outline: 2px solid #000; outline-offset: 2px; }Component variant:
pnpm audit:component --html examples/component.html --css examples/styles.css --level AA
pnpm codex:prep
pnpm codex:run
pnpm reportPage variant:
pnpm audit:url https://site.gov.au/path --level AA
pnpm codex:prep
pnpm codex:run
pnpm reportFor Drupal or GovCMS component libraries, export a static HTML of the Twig variant or Storybook story and run audit:component on that file.
- Run axe-core with tags
wcag2a wcag2aa wcag2aaa. - Add analysers:
- colour sampler for 1.4.3 and 1.4.11 to feed AI with borderline pairs.
- focus tracer for 2.4.7, 2.4.11, 2.4.12 to record visibility and obscuration.
- pointer target geometry for 2.5.8 with 24 by 24 CSS px minimum or spacing exception.
- Per criterion JSON record with status, evidence, and remediation list.
- Markdown summary grouped by principle.
- Keep raw artefacts under
.wcag-ai/:last.jsonrun contextprompts/per SC prompt filesout/AI outputs and human edited notesscreenshots/page and focus indicator images
- Many success criteria require judgement. Treat the AI as a reviewer, not an oracle.
- axe-core returns incomplete for some rules. Feed these to the AI with context.
- Confirm thresholds from the WCAG 2.2 normative pages when implementing analysers, for example 24 by 24 CSS px for 2.5.8.
- WCAG 2.2 Recommendation: https://www.w3.org/TR/WCAG22/
- Quick Reference: https://www.w3.org/WAI/WCAG22/quickref/
- Focus appearance and not obscured: see 2.4.11, 2.4.12, 2.4.13 pages in the spec
- Target size minimum: see 2.5.8 in the spec