> [!IMPORTANT] > ### ๐Ÿค– Read this with your AI agent โ€” don't read it by hand. > This repo is written agent-first. Point Claude Code, GitHub Copilot, Cursor, or any agent at it: > *"Read the README and AGENTS.md, then help me run / extend this."* > Structure + [`AGENTS.md`](../AGENTS.md) are optimized for agent comprehension. > This is the full overview: everything the README carried before it was cut to one screen. > The short version is the [README](../README.md).
a11y-loop logo # a11y-loop ### Write accessible UI by default. Verify it in a real browser. Say exactly what you couldn't check. a11y-loop makes AI coding agents write accessible UI by default, then proves what it can prove with a real browser audit across the states it built โ€” and tells you exactly what it could not check. [Demo GIF][demo-link] ยท [Documentation][docs-site-link] ยท [SKILL.md][docs-link] ยท [Benchmark](../evals/benchmark-results.md) ยท [Changelog](../CHANGELOG.md) ยท [Report Bug](https://github.com/ChanMeng666/a11y-loop/issues) ยท [Request Feature](https://github.com/ChanMeng666/a11y-loop/issues)
๐Ÿ“‘ Table of Contents - [๐ŸŒŸ Introduction](#-introduction) - [๐ŸŽฌ Demo](#-demo) - [๐ŸŽฅ Promo Video](#-promo-video) - [โœจ Key Features](#-key-features) - [๐Ÿ“Š Honest Coverage](#-honest-coverage) - [๐Ÿ”ฌ Field Notes](#-field-notes) - [๐Ÿ“ˆ Benchmark](#-benchmark) - [๐Ÿ› ๏ธ Tech Stack](#-tech-stack) - [๐Ÿ—๏ธ Architecture](#-architecture) - [๐Ÿš€ Getting Started](#-getting-started) - [โš™๏ธ Using it in CI](#-using-it-in-ci) - [๐Ÿ“– Usage Guide](#-usage-guide) - [โŒจ๏ธ Development](#-development) - [๐Ÿค Contributing](#-contributing) - [โค๏ธ Sponsor](#-sponsor) - [๐Ÿ“„ License](#-license) - [๐Ÿ™‹โ€โ™€๏ธ Author](#-author)
## ๐ŸŒŸ Introduction AI coding agents write inaccessible UI by default: in one 2024 study, 84% of the websites ChatGPT generated had accessibility problems ([W4A'24](https://doi.org/10.1145/3677846.3677854)). Asking for accessibility helps but does not finish the job: in a 2025 study, accessibility-oriented prompts cut expert-judged violation rates from 58% to 19%, yet barriers persisted, especially in semantic structure and status messages ([W4A'25](https://doi.org/10.1145/3744257.3744266)). Instructions are the setup; the verified audit is the check. a11y-loop is two things working together: 1. **An Agent Skill** โ€” plan rules that apply while the work is still being scoped (conformance target, per-component criteria, the product decisions that foreclose accessibility, color tokens before components), then standing generation rules (semantic HTML first, ARIA discipline, APG keyboard contracts, labels, focus visibility, AA contrast in light and dark, reduced motion, 24ร—24 targets) that apply while the agent is writing UI code. 2. **A Node CLI** (`a11y-loop audit` / `contrast --fix` / `diff`) that verifies the result in a real browser, feeds failures back to the agent to fix, and re-audits until the loop converges. **About the name.** `a11y` is the standard numeronym for *accessibility*: the first letter, the last letter, and the 11 letters between them replaced by their count โ€” the same construction as `i18n` (internationalization) and `k8s` (Kubernetes). Spelled out it reads as "ally", which is the pun the accessibility community has always liked, but `11` is the conventional form. `loop` is the other half of the method: the CLI is not a one-shot audit but a write โ†’ audit โ†’ fix โ†’ re-audit cycle that runs until the report converges. The skill follows the open [Agent Skills](https://agentskills.io/specification) standard, so the same `SKILL.md` is portable across 40+ clients โ€” Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, and more โ€” not just one vendor's agent. The CLI runs the same checks standalone in CI or any other pipeline. a11y-loop is a personal open-source project. It exists because the same handful of failures โ€” low contrast, missing alt text, empty links and buttons, unlabelled form inputs โ€” have topped the WebAIM Million every year for a decade, and the reason is not that they are hard to fix. It is that accessibility usually arrives as an after-the-fact compliance check, once the UI is already built and nobody wants to touch it. The goal here is to move it earlier: make accessibility a default of the development workflow rather than an audit at the end โ€” which matters more now that so much UI code is written by AI agents in the first place. "Earlier" now reaches back past the first line of code. Some of the decisions that settle whether an interface can be made accessible โ€” reordering that only works by dragging, a menu that only opens on hover, data that only exists as canvas pixels โ€” are made while the work is being scoped, and they are nearly free to change there and a rewrite afterwards. ยง0 of the skill puts those decisions in the plan, where they are still cheap. **What this is not.** a11y-loop does not claim compliance, does not guarantee accessibility, and does not replace manual testing or testing with assistive technology. A clean report means "no automatically detectable failures" โ€” see [Honest Coverage](#-honest-coverage) below for exactly what that does and doesn't cover. ## ๐ŸŽฅ Promo Video https://github.com/user-attachments/assets/33ff8672-7690-4581-9ceb-d95476883c54 ## ๐ŸŽฌ Demo ![a11y-loop fixing a seeded-violation demo page across five audit passes until it converges on zero violations](demo.gif) The demo page ([`demo/before/`](../demo/before)) is a fictional conference site seeded with **159 detectable failures** spanning the WebAIM Million's top six failure classes plus the failure modes documented for LLM-generated UI: clickable divs, hidden focus indicators, skipped heading levels, broken ARIA references, and ignored `prefers-reduced-motion`. Running the loop against it converges on [`demo/after/`](../demo/after) โ€” **0 violations** โ€” and `a11y-loop diff --before demo/before/report.json --after demo/after/report.json` reports: ``` Converged: all 159 violations fixed, none introduced ``` That count is for axe-core 4.14.0, the version a11y-loop pins, and it is the same on every run. The GIF above is rendered from the literal output of a11y-loop 0.3.2 at that axe-core version: the 159 failures, the quiet runs and the diff above. Its source and how to regenerate it are in [`docs/demo-gif-src/`](demo-gif-src). Up to and including 0.3.0 the count also moved by one between runs, 159 or 160, and the cause was the page, not the engine. One of its seeded failures is a scrolling sponsor ticker, and whether its tenth name was inside the viewport when axe read the page depended on how far the animation had got. The ticker now holds still for its first ten seconds before it scrolls forever, so every pass reads the same unscrolled frame. It still starts on its own and cannot be paused, so the seeded SC 2.2.2 failure is unchanged. `test/integration/demo.test.js` holds the count exactly: 150 outside the ticker and 9 inside it. `FIXED` / `NEW` / `REMAINING` status per finding, matched by stable fingerprint (rule + selector + element shape), is what makes that verdict checkable rather than asserted โ€” see [`demo/before/VIOLATIONS.md`](../demo/before/VIOLATIONS.md) and [`demo/after/FIXES.md`](../demo/after/FIXES.md) for the finding-by-finding record. ## โœจ Key Features `1` **Five rendering passes per audit** โ€” default (1280ร—720), dark mode, forced-colors mode, reduced-motion, and a 320ร—256 reflow viewport (the WCAG-sanctioned 400% zoom equivalent for SC 1.4.10) โ€” because most real failures only show up under a specific rendering condition, not on a single default-viewport load.
Diagram: the five passes feeding one report ```mermaid graph LR P1["Default
1280ร—720"] --> R["One JSON report
per audited page/state"] P2["Dark mode"] --> R P3["Forced-colors mode"] --> R P4["Reduced motion"] --> R P5["320ร—256 reflow
(400% zoom equiv., SC 1.4.10)"] --> R ```
`2` **Checks axe-core can't run** โ€” tab order, focus visibility (including focus-ring contrast), dialog focus trap / Escape / focus-return, target size (24ร—24 CSS px, SC 2.5.8), reduced-motion effectiveness, ambiguous link text, div-as-button, and positive `tabindex`. These sit alongside axe-core, not instead of it. The dialog and tab-order checks understand **portalled** dialogs โ€” a `
` in a portal, the way Base UI, Radix, Headless UI and anything on floating-ui ships one โ€” not only a native `` opened with `showModal()`. Three more judge what an `--interact` state's action did: where focus went after a client-side route change (`route-change-focus-lost`, SC 2.4.3), whether text that appeared reached a live region that was already in the page (`status-message-not-announced`, SC 4.1.3), and whether a live region arrived already holding its text (`live-region-injected-late`, SC 4.1.3, always needs-review). A dialog the state closed itself, with a submit inside it, is checked for focus return too. `3` **State coverage, not just page-load** โ€” `--interact` drives the states an agent just built (a modal opened, a menu expanded, a form in its error state) through the same five passes, because axe running once on page load finds nothing in a dialog that only misbehaves once it's open. `4` **Stable fingerprints power regression detection** โ€” `a11y-loop diff` matches findings across two reports by rule + selector + element shape and classifies each as FIXED, NEW (a regression โ€” this fails the command even if the total count went down), or REMAINING. A violation that a documented rule moved to needs-review (the forced-colors contrast routing) is RECLASSIFIED, never FIXED. `5` **Structured, honest output** โ€” JSON is the primary format: every violation carries its WCAG success criterion and its ACT rule ID where one exists, and every report carries a provenance block (axe-core version, browser, URL, viewport, timestamp, states exercised). SARIF v2.1 is available as a secondary format with [a documented limitation](#-honest-coverage) rather than a silent gap. A generated manual-review checklist, scoped to what was actually built, ships with every run. `6` **Exit codes are the loop's contract** โ€” `0` no violations / threshold met / no regression, `1` violations found / regression introduced, `2` tool error โ€” so a CI pipeline or an agent's own control flow can branch on the result without parsing prose. `7` **`contrast --fix`** โ€” checks a foreground/background pair against WCAG 2.x (1.4.3: 4.5:1 normal text / 3:1 large text; 1.4.11: 3:1 for UI components) and, on failure, suggests passing colors in both directions (lighter and darker) in OKLCh, so a fix stays close to the original hue instead of jumping to black or white. `8` **Portable as an Agent Skill** โ€” plain `SKILL.md` + `references/`, no proprietary format, works in any client implementing the open Agent Skills standard, not only Claude Code. `9` **Accessibility enters at the plan, not the pull request** โ€” ยง0 gives the agent the decisions to settle while the work is still being scoped: the conformance target for the relevant jurisdiction, per-component acceptance criteria, the color tokens (verified with `contrast --fix`, which needs no browser and no code), the structure, and the interaction states that will need auditing. It also names the product choices that foreclose accessibility โ€” drag-only reordering (SC 2.5.7), hover-only menus (SC 1.4.13), canvas-rendered data (SC 1.1.1), time limits (SC 2.2.1), CAPTCHA (SC 3.3.8) โ€” each with the alternative, while changing them is still a sentence rather than a rewrite. In Claude Code, the [optional plugin layer](#optional-the-claude-code-plugin-layer) enforces it at plan approval. ## ๐Ÿ“Š Honest Coverage Automated accessibility testing has a real, bounded scope, and a11y-loop says so in every report rather than implying otherwise: - Deque's own research puts automated coverage at **~57% of accessibility issues by volume** across a 13,000+ page / ~300,000 issue study โ€” but only **~31% of WCAG 2.2 AA success criteria** have *any* automated rule at all, and only **~13% are reliably automatable** end-to-end.
Diagram: WCAG 2.2 AA success criteria, by automation reach ```mermaid pie showData title WCAG 2.2 AA success criteria โ€” automation reach (Deque research) "Reliably automatable end-to-end (~13%)" : 13 "Have some automated rule, not fully reliable (~18%)" : 18 "No automated rule โ€” needs manual/AT review (~69%)" : 69 ``` Illustrates the same figures cited above, not a new metric โ€” a clean report never implies coverage of the other ~87%.
- **A clean a11y-loop report means "no automatically detectable failures were found" โ€” it is never a conformance or compliance claim**, and the tool will not tell you your app is accessible, compliant, or free of legal risk. No single score is ever produced. - Every run emits a **generated manual-review checklist**, scoped to the components actually built, naming the criteria that need a human and/or assistive-technology testing (screen reader behavior, descriptive quality of alt text and link text, logical reading order, caption/media alternative accuracy, cognitive accessibility) โ€” the direct inverse of "no manual testing needed." - axe-core's own `incomplete` results are surfaced as `needsReview`, not suppressed. See `evals/benchmark-results-2026-07.md` for a live example of two such findings being investigated and resolved rather than dismissed. - **The engine is wrong sometimes, and the honest thing is to say where.** A handful of rows have a known non-defect explanation โ€” contrast in the forced-colors pass being the loudest. They are written up in [Field Notes](#-field-notes), with what to check before dismissing one. Verifying a row is not the same as suppressing it, and a11y-loop suppresses nothing. The forced-colors contrast rows are the one class handled in code: they are reported under `needsReview` rather than as violations, each with a note that links to its field note, and they are still in every report. - **Every finding says how it was established.** `evidence` is `measured` when a value was read off the rendered page and compared with a threshold or a required attribute (every axe violation, and a11y-loop's static checks), `inferred` when the failure is a conclusion from what a driven, bounded probe did or did not see (a Tab walk that never reached an element, a dialog that let focus out, Escape that did not close it, a click handler with no keyboard path, focus left on the body after a route change, a status message outside any live region), and `needs-review` for every `needsReview` row. An `inferred` row is still a blocking violation. When the probe behind it ran out of budget, the row also carries `confidence: { level: "reduced", reason }`. Evidence never changes the exit code. - **Many rows, one cause, said once.** The report's `clusters` group rows that share a root cause: a colour pair for contrast (rule, foreground, background, required ratio), a selector pattern for everything else. Each cluster names the cause, gives the fix once, and lists its members by fingerprint. Every row stays in `findings`. On `demo/before`, 12 colour pairs account for its 104 contrast violations. - **SARIF caveat, stated honestly:** GitHub Code Scanning only displays SARIF results that carry a file-path location. a11y-loop's findings are located by rendered URL + CSS selector, which Code Scanning drops on ingestion โ€” a naive upload produces an empty Code Scanning view. SARIF output is provided for the Azure DevOps SARIF viewer, the VS Code SARIF extension, and other SARIF-consuming tooling; the JSON report remains the primary, complete format. - Findings are tagged with the **lowest WCAG version** that contains them (2.0 / 2.1 / 2.2), so you can filter to the subset a given jurisdiction actually enforces โ€” e.g. US ADA Title II and Section 508 to the 2.0/2.1 AA subset, EU EN 301 549 v3.2.1 to 2.1 AA (V4.1.1, published 2026-09-02, moves it to 2.2 AA once cited in the Official Journal, expected 2026-11-30 or 2026-12-16; as of 2026-10), NZ and UK to the full 2.2 AA set. ## ๐Ÿ”ฌ Field Notes Things a report says that are not what they look like. These came out of running a11y-loop against a production app across a full audit-fix cycle; each one cost an hour to work out from the report alone, so it is written down here. Every number below that can be reproduced from a checkout was reproduced at axe-core 4.12.1, and at 4.14.0 (checked 2026-10-19). ### `color-contrast` in the forced-colors pass is usually not a defect axe-core reads an element's foreground from `-webkit-text-fill-color`, falling back to `color` only when that is unset. Chromium's forced-colors emulation forces `color` and the background, and leaves `-webkit-text-fill-color` at the author's value โ€” on every element, including ones that never declared it. So in the forced-colors pass axe compares the author's foreground against the forced background. Dark-on-light text survives that comparison. Light-on-dark text collapses. Measured on [`test/fixtures/fixture-forced-colors-dark-surface.html`](../test/fixtures/fixture-forced-colors-dark-surface.html) at axe-core 4.14.0: | Element | Default pass | forced-colors pass | |---|---|---| | `#6d6975` text on `#ffffff` | 5.34:1, silent | silent | | `#e8e6ef` text on a `#141218` card | 15.04:1, silent | `color-contrast`, 1.23:1 โ€” *"foreground color: #e8e6ef, background color: #ffffff"* | Up to 0.3.2 this table gave 4.9:1 and 14:1 for the default pass. a11y-loop's own contrast math gives 5.34:1 and 15.04:1 for those pairs, which is what the fixture now pins. The tell is inside the finding: the reported foreground is the author's color while the background is the forced `#ffffff` or `#000000`. On a page with any dark surface this arrives as a cluster covering every line of text on it. On one production page it accounted for the large majority of a 153-finding run. **a11y-loop now reports these rows under `needsReview`, not as violations.** A `color-contrast` or `color-contrast-enhanced` violation from the forced-colors pass, and only from that pass, is routed there by rule id and pass name. (`color-contrast-enhanced` is AAA and not in the default run; it reads the foreground the same way, so it is routed too.) The row carries `routedFrom: "violation"`, a `note` with the explanation above and a `noteUrl` pointing here. It carries no colour suggestion, because one computed from the mismatched pair fails in the default pass: for the row above it would propose darkening `#e8e6ef` to `#77757d`. The same element failing in the default, dark, reduced-motion or reflow pass is still a violation, because a violation from any pass outranks this. On the fixture above the page went from one violation and exit 1 to zero violations and exit 0, with the row still reported. On `demo/before` and on every other fixture no row moved: each forced-colors contrast failure there also fails in another pass. `diff` lists a row that moved this way as `RECLASSIFIED`, not `FIXED`, so diffing a report from 0.3.2 or earlier against a new one does not count it as a fix. **Do not suppress the rule.** Confirm the pair in the default and dark passes, check forced colors by eye or with the OS setting, and say in the hand-off that forced colors was reviewed rather than measured. ### `div-button` on `` or a portal root is an outside-press handler Popup libraries dismiss on a press outside the popup by attaching a click handler to the document body or to the portal container. `div-button` looks for exactly that shape โ€” a click handler on an element with no role, no `tabindex` and no keyboard path โ€” so while a popup is open it reports `html > body` and the portal root. The keyboard equivalent of "press outside to dismiss" is Escape, which those libraries wire separately. So the question the row should send you to is `dialog-escape-does-not-close`: if Escape dismisses the popup, the `div-button` row on the body is a false positive of this check. It is a real failure only when that handler is the only way to activate something. ### Contrast sampled mid-animation measures a frame, not the design An element fading in is composited at partial opacity, and axe flattens that against what is behind it. Measured on a production page: `#6d6975` text at opacity 0.917 was read as `#797580`. The difference is small, but it is enough to move a value that sits near 4.5:1 across the line, and enough for two runs of the same page to disagree. If a contrast finding names a color you cannot find anywhere in the source, this is usually why. `openPage()` waits 120 ms after load for webfonts and short entrance animations; a longer reveal outlives it. Re-run, have the `--interact` state wait for the animation to settle before it returns, or read the number off the reduced-motion pass โ€” which is the stable one, provided the app honors the preference rather than ignoring it. ### `keyboard-unreachable` can mean the Tab budget ran out The keyboard survey presses Tab up to `MAX_TAB_STEPS` (60) times, then reports everything `tabbable()` expected but the walk never reached. Past 60 tab stops in one state, the tail of the document is reported unreachable because the budget ended, not because focus could not get there. The rule's own message names the budget, and when the walk used every press without wrapping, each `keyboard-unreachable` row also carries `confidence: { level: "reduced", reason }` saying so. The dialog trap probe does the same for `dialog-focus-not-trapped` when the verdict came from its wrap budget (`MAX_WRAP_TICKS`) rather than from focus reaching real content outside the dialog. A cluster of unreachable findings that is contiguous and sits at the end of document order is the signature. Confirm by hand with the five-minute keyboard test in [`references/manual-testing.md`](../skills/a11y-loop/references/manual-testing.md), and audit narrower states so each walk fits. ### `dialog-focus-not-returned` used to name the dialog's own Submit button (fixed in 0.4.0) Up to 0.3.x the dialog check took the last clicked element as the dialog's trigger. An `--interact` state that opened a dialog and then clicked the dialog's own Submit (to reach its validation-error state) made Submit the "trigger". Escape then closed the dialog, focus went back to the real opener, and the probe reported `dialog-focus-not-returned` because focus did not land on Submit. The 2026-10 benchmark found this on every newsletter-modal run (see `evals/benchmark-results.md`), and agents worked around it by calling `requestSubmit()` instead of clicking. The opener is now recorded at the moment the dialog opens: the last control activated or focused outside it. A dialog the state's own action closes is checked too, from a focus snapshot taken when the dialog closed (see the timing note below), not from wherever focus is when the state returns. The verdicts: | After the close | Escape probe | Closed by the state's own action | |---|---|---| | Focus on the opener | silent | silent | | Opener on the page, focus on another real element | violation | silent (after a save, a more logical place is allowed) | | Opener on the page, focus on `` | violation | violation | | Opener gone, focus on a heading, `main` or another real element | silent | silent | | Opener gone, focus on `` | violation | violation | | Opener unknown, focus on `` | needs-review | needs-review | The opener is unknown when the dialog was open on load, or a state opened it from script with no click or key press. `test/fixtures/fixture-dialog-submit-inside.html` reproduces the benchmark scenario and must produce no row. A report from 0.3.x with this row on a state that clicked inside the dialog is worth re-running. ### `status-message-not-announced` is a judgement about new text The check looks at text that appeared after a state's action, outside any live region that was in the page beforehand, with focus not moved to it. New text is also what a disclosure, a tab panel, a menu, a validation pass or a new view looks like, so the check skips: - text that any element names with `aria-describedby` or `aria-errormessage`, such as an inline error linked from its field, which is read when the field gets focus; - all new text in the state when, after the action, focus is on something other than the control the state activated: the page moved the user on purpose (an error summary, the first invalid field), and that is the author handling the change; - text inside a dialog that just opened; - text an `aria-expanded="true"` control names with `aria-controls`, menus, listboxes, tab panels, open `
` and popovers; - anything with a heading, landmark, table or two controls in it; - everything, when the state changed the route. What is left is reported as a violation only when the text is short, reads like a status ("saved", "error", "required", "3 results") and appeared next to the control the state activated. Anything else is `needsReview`. So a needs-review row here usually means "this text appeared and nobody was told; was it a status message?". If it was content the user asked to see, it is not one, and the row is answered. The status wording list is English only, so on a page in another language nearly every row is needs-review. `live-region-injected-late` is needs-review every time: the region carries the right role, and whether its first text is read out depends on the browser and screen reader (`role="alert"` often is). Confirm both with a screen reader, per [`references/manual-testing.md`](../skills/a11y-loop/references/manual-testing.md). ### The focus checks on a state wait up to a second for focus to land `route-change-focus-lost` and `dialog-focus-not-returned` both ask where focus went after something the state did. Both wait before answering. After a route change with focus on ``, focus gets up to 1 s to move somewhere real, polled every 50 ms, so a router that focuses the new heading once its data has loaded is not reported. After the Escape probe closes a dialog, focus gets the same 1 s. A dialog the state's own action closes is judged from a snapshot that is retaken every 50 ms until focus lands on a rendered element outside the dialog, or 1 s has passed. That snapshot is frozen early by the state's next click or key press: whatever the state does after the close (click plain text, which moves focus to `` in Chromium, or click another button) is not the dialog's doing, and does not change the verdict. So a page that restores focus more than a second after a close, or focuses its new view more than a second after a route change, is reported. For a route that waits on a long fetch, have the state wait for the focus move before it returns; the second is counted from when the state returns. For a dialog the second is counted from the close itself, so waiting in the state does not extend it, and a state that acts again within the second freezes the snapshot at that moment. The status-message check (`status-message-not-announced`) stands down entirely in a state where the page moved focus somewhere other than the control the state activated. So an unannounced status shown in that same state is not reported. This is a deliberate choice against false positives: focus moving to a new place is how many pages announce a change, and the check cannot tell which message that move covered. ### Up to 0.2.x, invoking the CLI through a link silently did nothing (fixed in 0.3.0) `src/cli.js` runs `main()` only when it is the program being run. Up to 0.2.x it decided that with `import.meta.url === pathToFileURL(process.argv[1]).href`. Node resolves `import.meta.url` through a junction or symlink to the real path while `process.argv[1]` keeps the path as invoked, so through any link the guard was false, nothing ran, and the process exited **0 with no output**, which reads like a pass: ```bash node D:\link-to-repo\src\cli.js --version # 0.2.x: prints nothing, exit 0 ``` That covered Windows worktrees reached through a junction and, more widely, `npm i -g` and `npx` on Linux and macOS, where npm installs the bin as a symlink. Since 0.3.0 the guard resolves `process.argv[1]` with `realpathSync` before comparing, and a test runs the CLI through a file symlink and a directory junction. If you are on 0.2.x, run the CLI by its real path. To embed it, import the entry point and call it directly; `main` is exported for this: ```js import { main } from './src/cli.js'; process.exitCode = await main(['audit', 'http://localhost:3000', '--json']); ``` ## ๐Ÿ“ˆ Benchmark A small, repeated comparison in [`evals/benchmark-results.md`](../evals/benchmark-results.md) (2026-10). Claude Sonnet 5.5 built the same six UI components three times without guidance and three times following the skill, running the audit loop to convergence. Every file was then checked by a11y-loop, IBM Equal Access and Lighthouse. - **Baseline: 45 a11y-loop violations across 18 files** (13 to 18 per run), plus 61 IBM violations and 13 failed Lighthouse audits. **After the loop: 0 a11y-loop violations and 0 failed Lighthouse audits**, at 1.5 to 2.2 audit iterations per file on average. - **What the loop leaves, by the independent engine:** one IBM finding in every converged file, a skip link placed outside any landmark. IBM's needs-review items barely moved. Lighthouse runs the same axe-core rules as a11y-loop, so its agreement is weak evidence. - **Context mattered too.** Baseline runs that could see this repository's `AGENTS.md` produced 0 a11y-loop violations, so the headline arms were run from a clean directory. Read it with its stated caveats: **three runs per condition, one model family, and only one of the three engines (IBM) independent of axe-core.** It is not a controlled study or a precise effect size. The 2026-07 single-run comparison is kept in [`evals/benchmark-results-2026-07.md`](../evals/benchmark-results-2026-07.md). It ran against the skill and CLI at 0.3.2, which include the ยง0 plan rules. Each task arrived as a finished component request, so the plan rules were not exercised, and it says nothing about planning. The 2026-07 run was against the v0.1.x skill. ## ๐Ÿ› ๏ธ Tech Stack - **Runtime:** Node.js โ‰ฅ 20, ESM (`"type": "module"`) - **Browser automation:** [Playwright](https://playwright.dev/) (Chromium) - **Accessibility engine:** [axe-core](https://github.com/dequelabs/axe-core) 4.14.0 via `@axe-core/playwright` 4.14.0, both pinned exactly so a fresh install runs the same rules (MPL-2.0 โ€” see [`THIRD-PARTY-NOTICES.md`](../THIRD-PARTY-NOTICES.md)) - **Color math:** [culori](https://github.com/Evercoder/culori) (OKLCh contrast fixes) - **Focus order:** [tabbable](https://github.com/focus-trap/tabbable) - **SARIF:** SARIF 2.1.0 written directly by `src/lib/format/sarif.js`, in the shape [axe-sarif-converter](https://github.com/microsoft/axe-sarif-converter) produced before 0.3.0 - **Agent integration:** the open [Agent Skills](https://agentskills.io/specification) standard โ€” `SKILL.md` + `references/`, no client-proprietary format - **Tests:** the built-in `node --test` runner, 778 tests across unit and integration suites (on `main` after 0.4.1), including a 32-fixture matrix of seeded violations and correct-behaviour regression guards, a demo end-to-end run, a forced-colors gradient regression test, and release guards: plugin-copy sync and version agreement, skill frontmatter against the Agent Skills spec, text encoding, every subcommand run with stdin closed, the CLI and hook run through a symlink and a junction, and a missing-browser audit that must exit rather than hang ## ๐Ÿ—๏ธ Architecture
System overview ```mermaid graph TD A["Agent Skill
skills/a11y-loop/SKILL.md + references/"] -->|"ยง0 plan rules"| P["Agent plans the UI work
conformance target, per-component criteria,
foreclosing decisions, color tokens, structure"] P -->|"contrast --fix, no browser needed"| E P --> B A -->|"ยง1 standing generation rules"| B["Agent writes UI code
HTML / JSX / Vue / Svelte / Astro / CSS"] B --> C["a11y-loop CLI"] C --> D["audit
5 passes: default, dark,
forced-colors, reduced-motion, 320px reflow"] C --> E["contrast --fix
WCAG 2.x + OKLCh suggestions"] C --> F["diff
FIXED / NEW / REMAINING / RECLASSIFIED"] D --> G["Playwright + Chromium"] G --> H["axe-core"] G --> I["a11y-loop's own checks
focus, dialog trap (native and portalled),
target size, reduced motion,
link text, div-button,
route-change focus, status messages"] D -->|--interact| J["Drive built states:
modal open, menu expanded, form error"] H --> K["JSON report
WCAG SC + ACT IDs + provenance"] I --> K J --> K K --> L["SARIF v2.1
(secondary format)"] K --> M["Manual-review checklist"] K -->|violations found| B F --> K ```
Sequence: one audit-fix-reaudit cycle ```mermaid sequenceDiagram participant Agent participant CLI as a11y-loop CLI participant Browser as Playwright + Chromium participant Axe as axe-core + a11y-loop's own checks Agent->>CLI: a11y-loop audit (after writing UI code) CLI->>Browser: render page across 5 passes
(+ --interact states, if any) Browser->>Axe: run checks against each rendered pass Axe-->>CLI: findings (WCAG SC, ACT ID, provenance) CLI-->>Agent: JSON report + manual-review checklist alt violations found Agent->>Agent: fix code Agent->>CLI: a11y-loop audit (re-run) else no violations Agent->>CLI: a11y-loop diff --before --after CLI-->>Agent: Converged โ€” FIXED/NEW/REMAINING summary end ```
The loop, in words: the skill sets standing rules while the agent writes UI; `a11y-loop audit` verifies the rendered result across five passes plus any built interaction states; violations feed back to the agent to fix; `a11y-loop diff` confirms convergence without new regressions; the JSON report (and its manual-review checklist) is the artifact of record, with SARIF offered as a secondary format for tools that consume it. ## ๐Ÿš€ Getting Started ### Prerequisites - Node.js โ‰ฅ 20 - A Chromium install for Playwright (installed in the steps below) > **Point your agent at this section.** Every command below is non-interactive, idempotent, and > safe to re-run โ€” no prompts, no TTY, no menus. An agent can install and verify the whole thing > without asking you to click anything. The one exception is called out explicitly at the end. ### Install everything (agent-runnable, one block) ```bash npm i -g a11y-loop a11y-loop install-browser a11y-loop doctor # exit 0 when an audit can run here # Then ONE of these: npx skills add ChanMeng666/a11y-loop -g -y # the portable skill, any Agent Skills client claude plugin install a11y-loop --marketplace ChanMeng666/a11y-loop # OR the Claude Code plugin (2.1.292+) ``` That is: the CLI, its browser, and either the portable skill or the optional Claude Code plugin. On Claude Code older than 2.1.292 the plugin takes two commands, `claude plugin marketplace add ChanMeng666/a11y-loop` and then `claude plugin install a11y-loop@chanmeng-a11y-loop --scope user`. On Codex it is `codex plugin marketplace add ChanMeng666/a11y-loop` and then `codex plugin add a11y-loop@chanmeng-a11y-loop`. No `git clone`: `npx skills add` fetches the skill, which also ships inside the npm package for a manual copy. Verify: ```bash a11y-loop --version test -f ~/.claude/skills/a11y-loop/references/plan-phase.md && echo "skill ok" # skill route claude plugin list | grep -A2 'a11y-loop@' # plugin route; expect: Status: โœ” enabled ``` Install the skill or the plugin, not both: the plugin carries its own copy, so the model ends up seeing `a11y-loop` *and* the directory-qualified `a11y-loop:a11y-loop`, paying the always-on token cost twice. If both names show up, delete `~/.claude/skills/a11y-loop` and keep the plugin. **Upgrading is a different command from installing** โ€” `claude plugin install` on something already installed reports "already installed" and does nothing: ```bash npm i -g a11y-loop@latest npx skills add ChanMeng666/a11y-loop -g -y # if using the skill claude plugin marketplace update chanmeng-a11y-loop # if using the plugin claude plugin update a11y-loop@chanmeng-a11y-loop ``` ### Installation (details) ```bash # Run it directly, no install npx a11y-loop # Or install the CLI globally npm i -g a11y-loop # Either way, install the Chromium build a11y-loop's own Playwright needs for audits a11y-loop install-browser # headless shell only, about 280 MB, enough for every audit a11y-loop install-browser --full # optional: the full Chromium too (about 715 MB in all), for audit --headed ``` Prefer working from source (e.g. to contribute)? Clone and install instead: ```bash git clone https://github.com/ChanMeng666/a11y-loop.git cd a11y-loop npm install node src/cli.js install-browser # the checkout's own Playwright; npx playwright resolves the same one here ``` If you keep browser binaries off the system drive, set `PLAYWRIGHT_BROWSERS_PATH` before running `a11y-loop install-browser` (and before running the test suite, which launches the same browser) โ€” e.g. `PLAYWRIGHT_BROWSERS_PATH=D:\playwright-browsers`. ### Installing the Agent Skill The one-line route, for any Agent Skills client: `npx skills add ChanMeng666/a11y-loop -g -y`. Or copy the directory by hand. The skill is a directory. Copy it wherever your client reads skills from โ€” no clone required, since `npm i -g a11y-loop` already put it on disk: ```bash SKILL_SRC="$(npm root -g)/a11y-loop/skills/a11y-loop" # Personal, all projects (Claude Code and other clients that read ~/.claude/skills) mkdir -p ~/.claude/skills && cp -r "$SKILL_SRC" ~/.claude/skills/ # Or project-scoped, committed with the repo mkdir -p .claude/skills && cp -r "$SKILL_SRC" .claude/skills/ ``` From a checkout, `skills/a11y-loop` is the same directory. Any client implementing the [Agent Skills specification](https://agentskills.io/specification) loads it the same way โ€” Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, and more. Re-running the copy upgrades in place, except over a skill that `npx skills add` installed: that one is a symlink, the copy fails on it, and re-running `npx skills add` is the upgrade. On Windows, use PowerShell (`New-Item -ItemType Directory -Force "$HOME\.claude\skills"`, then `Copy-Item -Recurse -Force "$(npm root -g)\a11y-loop\skills\a11y-loop" "$HOME\.claude\skills\"`) or Git Bash; cmd.exe is not supported. ### Optional: the Claude Code plugin layer The skill above is the whole product and it is portable. This repository also ships a thin Claude-Code-only layer that adds what the portable skill cannot do โ€” enforcement at plan approval and at the end of a session: ```bash claude plugin install a11y-loop --marketplace ChanMeng666/a11y-loop # Claude Code 2.1.292 or later # Older Claude Code: claude plugin marketplace add ChanMeng666/a11y-loop claude plugin install a11y-loop@chanmeng-a11y-loop --scope user ``` These are ordinary CLI commands โ€” no TTY, no prompts, safe to re-run. (They also exist as `/plugin install โ€ฆ` and `/plugin marketplace add โ€ฆ` inside a session, but an agent should use the CLI form.) Managing it later is equally non-interactive: `claude plugin disable a11y-loop`, `claude plugin enable a11y-loop`, `claude plugin uninstall a11y-loop@chanmeng-a11y-loop` โ€” add `-y` only if you pass `--prune`, which is the single flag that asks for confirmation. From a local checkout, `claude plugin marketplace add ./` registers the working copy instead, so the plugin tracks your edits rather than the published repo. **What installing it copies.** The plugin is the repository's `plugin/` folder, not the whole repository: the skill, the `/a11y-plan` command, the two hooks and their decision logic, a README and the license. That folder has no `package.json`, so the install fetches no packages. Up to 0.3.0 the plugin was the repository root, and its `package.json` and `package-lock.json` made Claude Code run a scripts-disabled `npm ci` in the plugin cache, about 24 MB of packages the plugin never used. `skills/a11y-loop/`, `src/lib/plan-gate.js` and `src/lib/audit-gate.js` stay the canonical sources; `npm run sync-plugin` copies them into `plugin/`, and a unit test fails while a copy differs. **Team-wide, declaratively.** `--scope project` writes `.claude/settings.json`, which you commit โ€” after that a clone needs no install commands at all: ```json { "extraKnownMarketplaces": { "chanmeng-a11y-loop": { "source": { "source": "github", "repo": "ChanMeng666/a11y-loop" } } }, "enabledPlugins": { "a11y-loop@chanmeng-a11y-loop": true } } ``` **The one thing an agent cannot do for you.** Project-scoped plugins and hooks are gated behind Claude Code's workspace-trust prompt the first time that directory is opened. That is a deliberate security boundary in Claude Code โ€” code from a repo should not execute because a file said so โ€” and nothing here can or should bypass it. `--scope user` avoids it entirely, which is why the agent-runnable block above uses user scope. It contributes: - a `PreToolUse` hook matched to `ExitPlanMode`. When a plan changes UI work and says nothing about accessibility, the plan is declined once and the `### Accessibility` section is handed back to fill in. It names any decision already in the plan that is hard to walk back later โ€” drag-only reordering, hover-only menus, infinite scroll, canvas-rendered data, time limits, CAPTCHA, autoplay. - a `Stop` hook. When Claude is about to finish a session that wrote UI files (`.html`, `.jsx`, `.tsx`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.mdx`, or `.js`/`.ts` whose new content touches the DOM) and has not run `a11y-loop audit` since the last such write, the stop is declined once. Claude is handed the files and the exact `audit` and `diff` commands to run. This is how the skill's ยง2 is enforced in Claude Code, including headless `claude -p` runs. - `/a11y-plan`, to ask for that section directly. Deliberate limits: it never returns `allow`, and on every non-deny path it returns at most a reminder with no permission decision, so it never suppresses your own plan approval; it declines a given plan **at most once**, so it cannot loop; a plan with no UI in it is passed silently; and `A11Y_LOOP_PLAN_GATE=off` disables it. The gate checks that the question was asked โ€” it cannot check that the answer is any good. That is still `a11y-loop audit` and a human. The audit gate has the same shape of limits. It declines a stop **at most once per session**, and never while `stop_hook_active` is true, which Claude Code sets when it is already continuing because of a stop hook. Any failure (no transcript, a shape it does not recognise, a state file it cannot write) lets the session end as it would have. `A11Y_LOOP_AUDIT_GATE=off` disables it. It checks that an audit ran after the last UI edit, not that the audit was clean or covered the states that changed. | | Portable skill | Plugin layer | |---|---|---| | Install | copy `skills/a11y-loop` | `claude plugin install a11y-loop@โ€ฆ` | | Works in | 40+ Agent Skills clients | the hooks and `/a11y-plan` in Claude Code; on Cowork and the Claude apps only the skill's rules | | Gives you | ยง0 planning, ยง1 generation, ยง2 the loop, ยง3 honest reporting | plan-mode enforcement, ยง2 enforcement at Stop, `/a11y-plan` | | Required? | yes | no | **Where the hooks do not run.** The Anthropic plugin directory also lists the plugin on Cowork and the Claude apps (web, desktop, mobile). Hooks do not run there, and there is no Node or browser for the audit, so those surfaces get the skill's plan, generation and reporting rules, and nothing is verified. **Plan gate without Claude Code.** The hook only exists inside Claude Code's interactive plan mode: it fires on `ExitPlanMode`, and that tool is absent under `claude -p` and in every other client. `a11y-loop plan-check` is the non-interactive, agent-controllable equivalent, and it is what CI and other agents (Codex, Cursor, Copilot, a headless Claude Code run) should use: ```bash a11y-loop plan-check --file plan.md # or --text "", or pipe the plan on stdin a11y-loop plan-check --file plan.md --json # verdict, isUiWork, hasA11yContent, missing items ``` It exits `0` when the plan carries accessibility content, is not UI work, or is too short to assess, `1` when a UI plan's accessibility content is thin or absent, and `2` on a usage or input error. It never prompts: with neither flag it reads stdin only when stdin is not a TTY, and gives up with `2` if a pipe stays open with no data. It is stateless, with no loop guard and no session cap, so the same plan always gets the same answer. The hook and the CLI call the same function in `src/lib/plan-gate.js`. The hook's interactive round trip remains unverifiable headlessly; that is now immaterial, because the decision is identical and fully tested, and the part only the hook adds (the stdin/stdout protocol, the once-per-plan guard) is tested by driving the script directly. **Audit gate without Claude Code.** The Stop hook reads Claude Code's own session transcript, so it has no portable equivalent: Codex, Cursor, Copilot and other clients rely on the skill's ยง2 text. Clients that do have a hook system (Codex, Cursor, Copilot, Gemini CLI) can be wired to run the audit at stop time; [`docs/agent-gates.md`](agent-gates.md) gives the verified events and a copy-pasteable script for each, and says what could not be verified. What enforces the loop for every client is CI. Run `a11y-loop audit` on the pages a change touches; it exits `1` when it finds violations, which fails the job (see below). ## โš™๏ธ Using it in CI The quickest route is the composite GitHub Action. It installs the CLI and the browser, audits, writes the result to the job summary, can diff against a baseline report and can keep one sticky comment on the pull request. It ships in release 0.4.0 and later; 0.3.2 and earlier do not contain it. It is listed on the [GitHub Marketplace](https://github.com/marketplace/actions/a11y-loop-audit) from v0.4.1, the version the `uses:` line below pins. ```yaml - uses: actions/setup-node@v4 # Node 20 or newer must be on the runner first with: node-version: 22 - uses: actions/cache@v4 # optional: keep the browser between runs with: path: ${{ runner.temp }}/pw-browsers key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - uses: ChanMeng666/a11y-loop@v0.4.1 with: target: http://localhost:3000 # a URL, or the path of an HTML file fail-on: violations # violations | new | never ``` Inputs: `target`, `interact`, `baseline`, `fail-on`, `sarif`, `pr-comment`, `version`, `browser`, `playwright-browsers-path`. Outputs: `violations`, `needs-review`, `manual`, `new`, `fixed`, `remaining`, `report-path`, `sarif-path`, `exit-code`. A tool error (exit code 2) always fails the step. `baseline` points at a JSON report from an earlier run; a missing file is a notice, not an error. `pr-comment: 'true'` needs `pull-requests: write` and edits one comment in place. [`examples/github-action/audit.yml`](../examples/github-action/audit.yml) is a complete workflow with the browser cache, a baseline taken from the latest run on `main`, and the comment. The action never posts a score or badge, and its summary says what a clean report does and does not mean. Without the action, the same thing in plain shell. Install the CLI and its browser once per job, then audit: ```bash npm i -g a11y-loop a11y-loop install-browser # headless shell only (the default); set PLAYWRIGHT_BROWSERS_PATH to cache it a11y-loop doctor # exits 0 when an audit can run here ``` This repository's own CI runs these steps against the packed tarball on Ubuntu, macOS and Windows (`install-smoke` in `.github/workflows/ci.yml`). ```bash a11y-loop audit http://localhost:3000 --json --out report.json ``` exits `1` if any violation is found (`2` on a tool error such as a missing browser), so a build step can gate on it directly: ```bash a11y-loop audit http://localhost:3000 --json --out report.json || exit 1 ``` `a11y-loop diff --before base.json --after head.json` in a PR check turns that into a regression gate: it fails only on genuinely `NEW` violations, so a PR that fixes ten and introduces none passes even though the raw count changed. `a11y-loop plan-check --file plan.md` gates the step before any of that: it exits `1` when a plan that changes UI work names no accessibility decisions, with no browser and no TTY needed. ## ๐Ÿ“– Usage Guide ### Basic Usage ```bash # Audit a running page (five passes: default, dark, forced-colors, reduced-motion, reflow) a11y-loop audit http://localhost:3000 # Audit an HTML file (served locally, never over file://) a11y-loop audit --file ./dist/index.html # Audit an HTML fragment directly โ€” the usual entry point for an agent mid-generation a11y-loop audit --html "" # Audit an interactive state (e.g. a modal after it opens) a11y-loop audit http://localhost:3000 --interact ./states/modal-open.mjs # Check a color pair against WCAG 2.x and get OKLCh fix suggestions a11y-loop contrast "#767676" "#ffffff" --fix # Compare two audit reports for regressions a11y-loop diff --before base.json --after head.json # Check a plan names its accessibility decisions before any UI is built a11y-loop plan-check --file plan.md ``` ### Advanced Configuration | Flag | Applies to | Effect | |---|---|---| | `--json` | audit, contrast, diff, doctor, plan-check | machine-readable output on stdout | | `--out ` | audit | write the JSON report to a file | | `--sarif ` | audit | also write SARIF v2.1 (see [Honest Coverage](#-honest-coverage)) | | `--interact ` | audit | export `const states = { name: async (page) => {} }` to drive built states | | `--headed` | audit | run a visible browser, for debugging; needs `a11y-loop install-browser --full` | | `--no-best-practice` | audit | omit axe best-practice rules (never blocking either way) | | `--quiet` | audit | one-line summary only | | `--verbose` | audit | list every row of every cluster; by default a cluster prints one example and "+N more like this" | | `--large` | contrast | large-scale text thresholds (โ‰ฅ24px, or โ‰ฅ18.5px bold) | | `--ui` | contrast | non-text / UI component threshold, 3:1, SC 1.4.11 | | `--fix` | contrast | suggest passing colors, lighter and darker, in OKLCh | | `--file ` | audit, plan-check | the HTML file to audit, or the plan to check | | `--text ""` | plan-check | the plan as an argument; with neither `--file` nor `--text`, a piped stdin is read | Run `a11y-loop --help` for the full, current reference. ### Reading a report The terminal output starts with a summary: what the exit code means, violations by evidence tier, needs-review and best-practice counts, how many colour pairs and clusters account for the violations, and how many manual checks remain. Root causes come next, one block per cluster with its cause, an example, the fix and `+N more like this`, then the rows no cluster explains. On `demo/before` that is 358 lines where it used to be 565, for the same 159 violations. The JSON report (`--out`, `--json`) is `schemaVersion: 2`. Everything from version 1 is where it was; version 2 adds `evidence` and, when they apply, `confidence`, `note`, `noteUrl`, `routedFrom` and `data.contrast` on findings, a top-level `clusters` array, and `summary.evidence`, `summary.clusters` and `summary.colourPairs`. SARIF results carry a message built from the finding, its measured values and its evidence tier. ### MCP server `a11y-loop mcp` serves the same commands as [Model Context Protocol](https://modelcontextprotocol.io/) tools over stdio, for clients that load MCP servers but not Agent Skills or a shell. It is the CLI's own code behind a thin protocol layer, not a separate engine: each tool result is the report the matching command writes with `--json`, trimmed for a model's context where noted. | Tool | Same as | Returns | |---|---|---| | `a11y_loop_audit` | `audit --json` | a run id, the verdict and counts, violations / needs-review / best-practice findings (fingerprint, rule, WCAG criterion, selector, passes, message, help URL, contrast suggestions; at most 50 per list), the manual checklist and the coverage statement. `out` writes the full report to disk; `timeoutMs` stops a long audit and returns an error | | `a11y_loop_diff` | `diff --json` | FIXED / NEW / REMAINING / RECLASSIFIED by fingerprint, plus `converged` and `regression`. Takes two run ids from this session or two report paths | | `a11y_loop_contrast_fix` | `contrast --fix --json` | the ratio, the verdict per level, and passing candidates in OKLCh | | `a11y_loop_plan_check` | `plan-check --json` | the verdict, the missing plan items and the template section | | `a11y_loop_doctor` | `doctor --json` | the environment facts, and the fix for each failing one | | `a11y_loop_manual_checklist` | the report's `manualChecklist` | what a person must still verify for a run | The full report and the checklist of each run are also resources, at `a11y-loop://runs//report` and `a11y-loop://runs//checklist`. Runs live in memory for the life of the connection, the last 20 at most. The audit reports progress after each rendering pass when the client sends a progress token. A missing browser comes back within seconds as a tool error that names `a11y-loop install-browser`. It never hangs. The server needs what the CLI needs: Node 20 or later, and the browser from `a11y-loop install-browser`. It also needs two packages that a normal install skips, because every other command works without them: `npm i -g @modelcontextprotocol/server zod`. With a global install (`npm i -g a11y-loop`) the command is then `a11y-loop mcp`. Without one, it is `npx -y -p a11y-loop -p @modelcontextprotocol/server@2.3.1 -p zod@4.6.7 a11y-loop mcp`, which resolves the packages on every launch. If they are missing, `a11y-loop mcp` says so, prints this command and exits `2`. Paths given to `file`, `interact` and `out` resolve against the server's working directory, so pass absolute paths. If you keep browsers outside the default location, set `PLAYWRIGHT_BROWSERS_PATH` in the server's `env`, since many clients do not pass your shell's environment through. **Claude Code** ```bash claude mcp add a11y-loop --scope user -- a11y-loop mcp claude mcp list # expect: a11y-loop: a11y-loop mcp - โœ” Connected ``` **Codex** (`~/.codex/config.toml`) ```toml [mcp_servers.a11y-loop] command = "a11y-loop" args = ["mcp"] ``` **Cursor** (`~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project) ```json { "mcpServers": { "a11y-loop": { "command": "a11y-loop", "args": ["mcp"] } } } ``` On Windows, a client that spawns commands without a shell cannot run the `a11y-loop.cmd` shim directly. Use `"command": "cmd", "args": ["/c", "a11y-loop", "mcp"]` there. The honesty rules apply to tool results exactly as to CLI output. The tools return the same reports, with the same coverage statement and manual checklist, and nothing in the protocol layer turns a finding count into a verdict about the page. An agent that relays only the violation count has dropped the half of the result that says what was not checked. **Publishing to the MCP registry.** It is not published there yet (checked 2026-10-20). `package.json` carries the registry's ownership marker, `"mcpName": "io.github.ChanMeng666/a11y-loop"`. The registry needs no file in the repository; `server.json` only has to exist when `mcp-publisher publish` runs, after the npm release. It must carry the fixed `mcp` argument, which `mcp-publisher init` does not know about: ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.ChanMeng666/a11y-loop", "description": "Browser accessibility audit across five passes, diff, contrast fixes and a plan check.", "repository": { "url": "https://github.com/ChanMeng666/a11y-loop", "source": "github" }, "version": "", "packages": [ { "registryType": "npm", "identifier": "a11y-loop", "version": "", "runtimeHint": "npx", "transport": { "type": "stdio" }, "packageArguments": [{ "type": "positional", "value": "mcp" }] } ] } ``` ```bash mcp-publisher login github mcp-publisher publish ``` ## โŒจ๏ธ Development ```bash npm install node src/cli.js install-browser # once, or after a Playwright version bump npm test # full suite npm run test:unit # unit tests only npm run test:integration # integration tests only (drives real Chromium) ``` The optional plugin layer is plain Node with no dependencies. Exercise the plan gate directly: ```bash echo '{"session_id":"s1","tool_name":"ExitPlanMode","tool_input":{"plan":"Add a settings page in React: a modal dialog, a members table with row actions, tabs, and a dark mode toggle styled with Tailwind CSS."}}' \ | node plugin/hooks/plan-gate.mjs # expect a "deny" verdict claude plugin validate ./plugin --strict # shape only โ€” see AGENTS.md on why this is not proof ``` Silence and exit 0 is the gate's normal answer, so an example that prints nothing proves nothing: the plan has to clear 40 characters and be unambiguously about UI, or you are testing the pass path by accident. Re-running the *same* plan returns a reminder only (no decision) rather than `deny` โ€” the loop guard spends each plan's hash once, deliberately โ€” so prefix with `CLAUDE_PLUGIN_DATA="$(mktemp -d)"` for a clean verdict. See [`AGENTS.md`](../AGENTS.md) for the rest of the plugin-layer gotchas. See [`AGENTS.md`](../AGENTS.md) for AI-agent-oriented project conventions, the fixture-manifest testing pattern, the loop discipline expected when touching `demo/` or other UI, and the rule that keeps `skills/` free of any Claude-Code-specific mechanism. ## ๐Ÿค Contributing Contributions make the open-source community an amazing place to learn and create. Please read the [Contributing Guide](../CONTRIBUTING.md) and the [Code of Conduct](../CODE_OF_CONDUCT.md) before you start, and use the provided issue / pull-request templates. ## โค๏ธ Sponsor If this project helps you, please consider supporting its development: [Sponsor on GitHub](https://github.com/sponsors/ChanMeng666) ยท [Buy Me a Coffee](https://buymeacoffee.com/chanmeng66u) For questions and help, see [SUPPORT.md](../SUPPORT.md). For security issues, see [SECURITY.md](../SECURITY.md). ## ๐Ÿ“„ License This project is released under the [MIT](../LICENSE) license. axe-core, a dependency this project uses to run its checks, is separately licensed under MPL-2.0 โ€” see [`THIRD-PARTY-NOTICES.md`](../THIRD-PARTY-NOTICES.md) for full attribution. ## ๐Ÿ™‹โ€โ™€๏ธ Author **Chan Meng** ยท [chanmeng.dev@gmail.com](mailto:chanmeng.dev@gmail.com) ยท [github.com/ChanMeng666](https://github.com/ChanMeng666) [demo-link]: demo.gif [docs-link]: ../skills/a11y-loop/SKILL.md [docs-site-link]: https://ChanMeng666.github.io/a11y-loop/ ---
Chan Meng

Chan Meng
Need a custom app like this one? I build them โ€” let's talk.

chanmeng.dev@gmail.com ยท github.com/ChanMeng666