# CLAUDE.md

Guidance for Claude Code when working in this repository.

## What this is

`wcag-audit` is a small Express + Playwright service (see README.md for full details) that
runs a multi-page WCAG accessibility audit with `axe-core` and renders a self-contained
HTML report per run, plus CSV/Excel/PDF exports.

It was extracted from the `ui-compare` project, where the same feature still lives
alongside the visual-diff and post-deploy flows. Fixes to shared files
(`cf-clearance.js`, `sitemap-loader.js`, `xvfb.js`) are worth considering for both repos —
they were copied, not shared, so they can drift.

## Architecture at a glance

- `webapp.js` — Express routes only. Owns the run lifecycle (`runs` in-memory map), SSE
  broadcasting, lead capture, and the Cloudflare-clearance endpoints. No audit logic here.
- `src/mailer.js` — sends the lead notification over SMTP (env-configured), and appends
  every lead to `leads.jsonl` whether or not the send succeeded.
- `src/api-auth.js` — API key check for the `/api/v1` routes. **Fails closed**: with no
  `WCAG_API_KEYS` set every API route returns 503 rather than running unauthenticated.
  Keys are compared timing-safely. See `API.md` for the contract.

Both the browser route and the API go through one `startAudit()` in `webapp.js`, so the
two can't drift. Its `unlocked` flag is the report gate — browser runs start locked and
are opened by `POST /lead`; API runs are authenticated already, so they start unlocked.
- `src/a11y-checker.js` — the audit itself: launches Chromium, injects `axe-core` into each
  page, runs the live keyboard (Tab-walk) and 320px-reflow passes, and classifies each
  violation into a `region` (`header`/`nav`/`footer` vs `main`) by walking up from the
  failing element to the nearest landmark. `WCAG_VERSIONS` maps the selectable
  version/level (2.0/2.1/2.2 × A/AA, cumulative tag sets, default 2.2 AA) to axe tag sets.
- `src/a11y-reporter.js` — `generateA11yReport(result, outDir)`. Groups non-`main` hits into
  one "common/site-wide" issue per rule (since the same header/nav/footer renders on every
  page) and keeps `main` hits page-specific, then writes the HTML report plus the CSV,
  Excel (`exceljs`) and PDF (a second headless page calling `page.pdf()`) exports.
- `src/sitemap-loader.js` — loads a sitemap, recursing into a `<sitemapindex>`'s children,
  and samples across page templates (`sampleUrlsByTemplate`) so a large site's page types
  all get covered instead of just the first N URLs.
- `src/cf-clearance.js` / `src/xvfb.js` — the "solve the Cloudflare challenge once in a real
  headed browser, save the session per host, replay it" mechanism. `launchForScan` and
  `withClearance` are what every Playwright launch/context in this repo should go through.
- `public/` — `index.html` + `a11y-app.js` (the form/SSE wiring) and `cf-clearance.js`
  (the shared "Solve Cloudflare challenge" button + `showFieldError`).

## Conventions already in place

- Files use `'use strict'` and CommonJS (`require`/`module.exports`), not ES modules.
- In-page evaluation code (`page.evaluate` callbacks) is intentionally written in ES5-style
  `function` syntax with `var` — it runs inside the target page's own JS context, not Node,
  so keep it dependency-free and broadly compatible.
- Comments explain *why*, not *what*. Follow that pattern — don't add restating comments.
- Frontend files are also ES5-style (`var`, `function`) with no build step.

## Running / testing locally

```bash
npm start                 # http://localhost:4200 (PORT env var to override)
```

There is no test suite. To verify a change, run the app and drive a real audit through the
UI or `POST /a11y-audit`, then inspect `web-reports/<runId>/a11y-report.html`.

## The report gate

The dashboard (`GET /result/:runId`) is public; the detailed report is not. `web-reports/`
is deliberately **not** mounted with `express.static` — if you add that back, the gate is
gone, because anyone could fetch `/reports/<runId>/a11y-report.html` directly. Reports are
served by `_sendGated` in `webapp.js`, which checks `runs[runId].unlocked`, a flag only
`POST /lead` sets.

Two consequences worth remembering:
- The unlock lives in the in-memory `runs` map, so a restart re-locks every past run.
- The report HTML links to its own PDF via the absolute `/report/<runId>/pdf` path, not a
  relative filename, because it's no longer served from the same directory as its siblings.

## Things to be careful about

- `web-reports/` accumulates one directory per run; it's not cleaned up automatically.
  Don't assume it's disposable without checking for in-progress runs (`runs` map).
- The default port is **4200**, deliberately different from `ui-compare`'s 4100 so both can
  run side by side.
- Every Playwright launch should go through `launchForScan`/`withClearance` rather than
  calling `chromium.launch()`/`newContext()` directly — that's what applies the stealth
  args, the non-headless UA rewrite, and any saved Cloudflare clearance session.
