# WCAG 2.2 AA — Internal Audit & Fix Backlog **Status:** infrastructure shipped (Phase 10 #73), fix-sprint deferred **Spec:** [`docs/improvement-plan/10-compliance-polish/wcag-internal.md`](../improvement-plan/10-compliance-polish/wcag-internal.md) **Last updated:** 2026-05-26 This document is the operator-facing companion to the WCAG-internal task. It explains: 1. What automation now guards accessibility on every PR (the "infrastructure" that just shipped). 2. The current baseline of pre-existing violations and their severity. 3. The top-10 prioritized fix backlog. 4. How to add a new component with a11y coverage from day-1. 5. Manual screen-reader testing setup for NVDA (Windows) and VoiceOver (macOS). ## 1. What's wired today | Surface | Tool | Trigger | | -------------------------------- | ------------------------------------------------------------------- | ---------------------- | | Component unit tests (jsdom) | `vitest-axe` via `@toolctrl/ui-app/test-setup/axe.ts` | `pnpm test:a11y` | | Static heuristic checks | `apps/web/app/utils/test/a11y.tsx` (`expectNoA11yViolations`) | `*.a11y.test.tsx` | | Live design feedback | Storybook `@storybook/addon-a11y` (WCAG-2.2-AA preset) | `pnpm --filter storybook dev` | | Lint rules | `eslint-plugin-jsx-a11y` (`plugin:jsx-a11y/recommended`) | `pnpm lint` | | Brand-token contrast | `scripts/verify-color-contrast.mjs` (zero-dep, fails CI on regress) | `pnpm verify:color-contrast` | | Browser-level WCAG sweep | `@axe-core/playwright` | `pnpm verify:e2e-a11y` | | CI gate | `.github/workflows/a11y.yml` + `.gitea/workflows/a11y.yml` | every PR + push to main | | Keyboard "skip to content" | `apps/web/app/components/core/SkipToContent.tsx` | rendered in `AppLayout` | Browser-level mobile public coverage includes landing, legal pages, error pages, the feature-disabled page, footer menus, narrow viewports, and a compact 320px viewport. These checks assert no horizontal overflow and no critical axe violations for the public mobile surface. The six high-traffic components have an explicit axe-core spec: - `apps/web/app/components/ui/Button.axe.test.tsx` - `apps/web/app/components/ui/Input.axe.test.tsx` - `apps/web/app/components/ui/Card.axe.test.tsx` - `apps/web/app/components/ui/Dialog.axe.test.tsx` - `apps/web/app/components/ui/Alert.axe.test.tsx` - `apps/web/app/components/ui/Form.axe.test.tsx` Focus-visible was added to the top-5 custom components missing it: | Component | Change | | ---------------------------------------------------------------------- | ------ | | `apps/web/app/components/ui/card.tsx` | New `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` when the card is wrapped as a link / button. | | `apps/web/app/components/ui/dialog.tsx` (DialogClose) | Migrated legacy `focus:` ring to `focus-visible:` (keyboard-only ring). | | `apps/web/app/components/layouts/buttons/ProfileButton.tsx` | Same migration. | | `apps/web/app/components/layouts/buttons/SearchButton.tsx` | Same migration. | | `apps/web/app/components/layouts/buttons/NotificationsButton.tsx` | Same migration. | ## 2. Baseline of pre-existing violations `eslint-plugin-jsx-a11y/recommended` is now applied as errors; rules with existing violations are listed in [`.eslintrc.a11y-baseline.json`](../../.eslintrc.a11y-baseline.json) and temporarily disabled until each count reaches zero. The current totals: | Rule | Count | Severity | | --------------------------------------------- | ----- | -------- | | `jsx-a11y/no-autofocus` | 81 | P2 | Both P0 rules (`anchor-has-content`, `heading-has-content`) and all four P1 rules (`label-has-associated-control`, `click-events-have-key-events`, `no-static-element-interactions`, `no-noninteractive-element-interactions`) reached zero on 2026-05-20 and have been promoted back to `error` — see the changelog below. `anchor-is-valid` and `no-noninteractive-tabindex` reached zero on 2026-05-25 and have also been promoted. `media-has-caption` reached zero on 2026-05-28 and has also been promoted. Baseline total: **81** (P2-only remaining). **Reduction policy** (also encoded in the baseline file): - The baseline is monotonically non-increasing. `scripts/verify-a11y-baseline.mjs` compares current counts to baseline and fails CI on regression. - When a rule's violation count reaches **zero**, remove the override from `.eslintrc`, drop the entry from `.eslintrc.a11y-baseline.json`, and add a changelog line below. ### Promotion changelog - **2026-05-20** — `jsx-a11y/anchor-has-content` and `jsx-a11y/heading-has-content` reached **0** violations and were promoted back to `error` (the `off` overrides were removed from `.eslintrc`). The two reusable wrappers that tripped the rules — `PaginationLink` (`apps/web/app/components/ui/pagination.tsx`) and `StepperTitle` (`apps/web/app/components/ui/stepper.tsx`) — now destructure `children` and render it explicitly inside the `` / `

` so the rules can statically verify content instead of seeing a bare `{...props}` spread. - **2026-05-20** — all four **P1** rules (`label-has-associated-control` 15, `click-events-have-key-events` 13, `no-static-element-interactions` 11, `no-noninteractive-element-interactions` 2) reached **0** violations and were promoted back to `error` (the `off` overrides were removed from `.eslintrc`) as part of the #73 WCAG sweep. Fix patterns: form `