# a11y-audit — the fleet WCAG 2.1 AA gate

A real axe-core run against a built app shell, microfrontend or website, wired to
fail a build on `serious` **and** `critical` violations, with an explicit
allowlist for the exceptions you have decided to live with.

It exists because the thing it replaces did not work. Several repos had an
`@axe-core/playwright` spec checked in, but:

- no CI workflow ever executed it, and
- where it did assert, it asserted `impact === 'critical'` only, through
  `expect.soft(...)`, which records a failure and then lets the run pass.

So the fleet's accessibility posture was "not assessed" while looking assessed.

## What it does

For every configured route × viewport:

1. serves the built app (or points at a running one),
2. navigates, waits for the DOM to stop changing, and refuses to audit a page
   that never painted,
3. runs axe-core with the WCAG 2.1 A + AA tag set,
4. splits every failing DOM node into **blocking** / **allowlisted** /
   **advisory**,
5. writes a JSON report and a GitHub step summary,
6. exits non-zero when anything blocking survives.

Exit codes:

| Code | Meaning |
| --- | --- |
| `0` | no blocking violations |
| `1` | blocking violations (or stale allowlist entries with `failOnStaleAllowlist`) |
| `2` | the audit could not run — invalid config, app never came up, a route failed to load or never rendered |

Exit `2` matters as much as exit `1`. An audit that could not look at a page is
reported as a failure to audit, never as a pass.

## Install in a repo

```bash
bun add -d @playwright/test @axe-core/playwright
bunx playwright install --with-deps chromium
```

`@burdenoff/fe-libs` ships the runner as a package `bin`, so either of these works
from the repo root:

```bash
a11y-audit
bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts
```

Add the script to `package.json`:

```json
"a11y": "bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts"
```

Playwright and axe are imported dynamically, so fe-libs itself does not drag a
browser automation stack into every consumer of the component library.

## Config

`a11y-audit.config.json` at the repo root (or pass a path as the first argument).

```jsonc
{
  "name": "vibecontrols-app",
  "serve": { "staticDir": "dist", "spa": true },
  "routes": [
    { "path": "/", "name": "landing" },
    { "path": "/auth/login", "name": "auth-login" }
  ],
  "viewports": [
    { "name": "desktop", "width": 1280, "height": 800 },
    { "name": "mobile", "width": 390, "height": 844 }
  ],
  "settleMs": 1000,
  "domQuietMs": 2000,
  "allowlist": []
}
```

### `serve` — exactly one mode

| Mode | Use for | Example |
| --- | --- | --- |
| `staticDir` | app shells and websites, which build to a servable `dist/` | `{ "staticDir": "dist", "spa": true }` |
| `command` + `port` | microfrontends, which build to a library and need their Vite harness to render | `{ "command": "bun run dev --port 4180", "port": 4180 }` |
| `baseUrl` | an already-running target (preview deploy, alpha, prod) | `{ "baseUrl": "https://alphaapp.vibecontrols.com" }` |

`spa: true` (the default) serves `index.html` for extension-less paths so
client-routed URLs resolve. Missing *assets* still 404 — a broken bundle must not
be laundered into a page load.

`--base-url=URL` on the command line overrides whatever the config says, which is
how you point the same config at alpha or prod.

### Determinism knobs

| Key | Default | Why |
| --- | --- | --- |
| `blockExternalRequests` | `true` | Aborts every request leaving the app's own origin. Without it, axe races backend latency: the same route audits a splash screen on one run and a rendered page on the next. Set `false` when auditing a live deployment where the backend is part of the subject. |
| `settleMs` | `1500` | Fixed delay after `load` before quiescence polling starts. |
| `domQuietMs` | `1500` | The DOM must stop changing (text length and element count) for this long before axe runs. |
| `minTextLength` | `100` | A route that renders less than this much text is reported as **not audited** (exit 2), never as a pass. |
| `navigationTimeoutMs` | `45000` | Per-navigation and per-quiescence ceiling. |

Per route you can also set `waitForSelector` and a route-specific `settleMs`.

### `failOn`

Defaults to `["critical", "serious"]`. It may be **widened** (adding `moderate`),
never narrowed — a config that drops `critical` or `serious` is rejected. That is
the fleet floor and the gate refuses to run below it.

## The allowlist

There is deliberately **no** way to disable a rule. The only suppression
mechanism is an allowlist entry, and every entry must say why.

```jsonc
{
  "rule": "color-contrast",
  "reason": "Stripe's hosted card iframe; contrast is fixed upstream and we cannot restyle it.",
  "routes": ["/billing/*"],
  "selectors": ["#stripe-card-frame"],
  "expires": "2026-12-31",
  "ticket": "BOFF-1234"
}
```

| Field | Required | Rules |
| --- | --- | --- |
| `rule` | yes | one axe rule id. Wildcards (`*`, `**`, `.*`) are rejected. |
| `reason` | yes | ≥ 20 characters of actual prose. `TODO`, `TBD`, `known issue`, `legacy`, `n/a` and "placeholder + nothing substantive" (`TODO: fix later`) are rejected. |
| `routes` | no | exact path or `/prefix/*` subtree. A scope naming a route that is not audited fails the run as a typo. |
| `selectors` | no | matches the node's own axe target, or an ancestor prefix of it on a combinator boundary. `#billing` does **not** match `#billing-panel`. |
| `expires` | no | ISO `YYYY-MM-DD`. Past its end-of-day the entry suppresses nothing and the violations under it start failing again. |
| `ticket` | no | Linear id, surfaced in the report. |

Omitting both `routes` and `selectors` is allowed but it is a fleet-wide
exemption for that rule, and the report labels it as unscoped so it is visible in
review.

Entries that matched nothing in a run are reported as **stale**. Set
`failOnStaleAllowlist: true` once a repo's list is clean to keep it that way.

## Output

- console report (route by route, rule by rule, with the failing selectors),
- `a11y-report.json` (path configurable via `reportPath` or `--json=`), which is
  what you attach to a CI artifact,
- a markdown table appended to `$GITHUB_STEP_SUMMARY` when running in Actions.

## CLI flags

```
a11y-audit [configPath]
           [--base-url=URL]   audit a running deployment instead of serving dist
           [--json=PATH]      where to write the JSON report
           [--route=/path]    repeatable; audit only these routes
           [--quiet]          suppress the console report, keep the verdict
```

## Wiring it into CI

Copy `a11y-check.yml.tmpl` from
`~/products/dev/platform/context/cicd/templates/` into `.github/workflows/`.
Rollout guide: `~/products/dev/platform/context/ui-simplification/HOW_TO_ADD_A11Y_CI.md`.
