> [!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
### 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

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/
---