14 KiB
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
Last updated: 2026-05-26
This document is the operator-facing companion to the WCAG-internal task. It explains:
- What automation now guards accessibility on every PR (the "infrastructure" that just shipped).
- The current baseline of pre-existing violations and their severity.
- The top-10 prioritized fix backlog.
- How to add a new component with a11y coverage from day-1.
- 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.tsxapps/web/app/components/ui/Input.axe.test.tsxapps/web/app/components/ui/Card.axe.test.tsxapps/web/app/components/ui/Dialog.axe.test.tsxapps/web/app/components/ui/Alert.axe.test.tsxapps/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 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.mjscompares 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-contentandjsx-a11y/heading-has-contentreached 0 violations and were promoted back toerror(theoffoverrides were removed from.eslintrc). The two reusable wrappers that tripped the rules —PaginationLink(apps/web/app/components/ui/pagination.tsx) andStepperTitle(apps/web/app/components/ui/stepper.tsx) — now destructurechildrenand render it explicitly inside the<a>/<h3>so the rules can statically verify content instead of seeing a bare{...props}spread. - 2026-05-20 — all four P1 rules
(
label-has-associated-control15,click-events-have-key-events13,no-static-element-interactions11,no-noninteractive-element-interactions2) reached 0 violations and were promoted back toerror(theoffoverrides were removed from.eslintrc) as part of the #73 WCAG sweep. Fix patterns: form<label>s were associated with their controls viahtmlFor+idor had nested accessible text flattened to within depth-2; decorative/empty labels captioning non-form widgets (Monaco/TipTap editors, options tables) became<div>/role="group"captions, andConfirmModalinputs gained anaria-label; genuine div-buttons became<button>; rows with nested interactive children gainedrole="button"+tabIndex+onKeyDown; deadonClick={e => e.preventDefault()}handlers on plain divs were removed; theHoverEffect<img>lightbox trigger was wrapped in a<button>. Four genuine mouse-only click-to-focus hit-area enhancements —input-group,InputRichTextEditor, the Novel editor wrapper, and theSiteTypeCardwrapper — keep a justified inlineeslint-disablebecause adding a focusable role there would create a redundant tab stop ahead of an already-keyboard-accessible control. - 2026-05-25 —
jsx-a11y/anchor-is-validandjsx-a11y/no-noninteractive-tabindexreached 0 reproduced violations and were promoted back toerror. The Storybook Popover demo now uses real button controls for menu actions, and the stale tabindex baseline entry was removed after the verifier reproduced zero hits across the tracked app + Storybook scope. - 2026-05-28 —
jsx-a11y/media-has-captionreached 0 violations and was promoted back toerror.AudioOrVideoPlayer(apps/web/app/components/ui/media/AudioOrVideoPlayer.tsx) now renders a captions track for both audio and video media by default, while still allowing callers to pass real VTT caption metadata. - 2026-05-28 —
jsx-a11y/no-autofocusdropped from 86 to 81 violations.ButtonPrimaryandLinkOrAhrefnow preserve theirautoFocuscaller contract through programmatic focus instead of passingautoFocusto DOM nodes.
3. Top-10 prioritized fix backlog
Severity tiers are deliberately small so a single PR can usually clear one.
| # | Severity | File | Rule / issue | Recommended fix |
|---|---|---|---|---|
pagination.tsx |
anchor-has-content |
✅ Fixed 2026-05-20 — see promotion changelog. | ||
stepper.tsx |
heading-has-content |
✅ Fixed 2026-05-20 — see promotion changelog. | ||
apps/web/app/components/** |
label-has-associated-control (15x) |
✅ Fixed 2026-05-20 — see promotion changelog. | ||
click-events-have-key-events (13x) |
✅ Fixed 2026-05-20 — see promotion changelog. | |||
no-static-element-interactions (11x) |
✅ Fixed 2026-05-20 — see promotion changelog. | |||
no-noninteractive-element-interactions (2x) |
✅ Fixed 2026-05-20 — see promotion changelog. | |||
media-has-caption (2x) |
✅ Fixed 2026-05-28 — reusable media player renders captions tracks. | |||
| 8 | P2 | Modal/search/onboarding inputs | no-autofocus (81x) |
Replace with programmatic focus on mount so screen-reader hears modal title first. |
no-noninteractive-tabindex |
✅ Promoted 2026-05-25 — stale baseline entry reproduced as 0. | |||
packages/ui/src/stories/Popover.stories.tsx |
anchor-is-valid |
✅ Fixed 2026-05-25 — demo menu actions use buttons. |
Track per-fix PRs in the Issue tracker with
label area:accessibility.
4. Adding a new component with a11y coverage from day-1
// packages/ui-app/src/MyWidget.tsx
export function MyWidget({ label }: { label: string }) {
return <button aria-label={label}>...</button>;
}
// packages/ui-app/src/MyWidget.a11y.test.tsx
import { MyWidget } from "./MyWidget";
import { generateA11yTest } from "../test-utils/a11y-template";
generateA11yTest(MyWidget, { label: "Close" });
That's it — the helper renders the component, runs axe-core with the project's WCAG-2.2-AA preset, and fails if any rule is violated.
For interactive components (Modal, Combobox, DataTable), add manual tests for:
- Focus trap (Tab cycles within, Escape closes).
- Keyboard activation (Enter/Space).
- Screen-reader-announced state changes (
aria-live).
Storybook stories are picked up automatically by addon-a11y so designers
see the same violations live.
5. Manual screen-reader testing
WCAG 2.2 AA cannot be fully verified by axe-core — roughly 57 % is automatable per Deque. The remaining ~43 % needs an actual screen reader.
NVDA on Windows (Chrome)
- Install NVDA (free, open-source).
- Start NVDA before opening Chrome (
Insert + Nopens the menu). - Navigate the staging site keyboard-only:
Tabadvances focus,Shift+Tabreverses.Hjumps between headings;Kbetween links;Fbetween form fields.Insert + F7opens the elements list (headings/links/landmarks).
- Validate flows: Login, Magic-Link, CRM contact create, DSGVO export.
- Capture findings in
docs/accessibility/screen-reader-smoke-tests-<date>.md(one section per flow; format:Pass / Findings).
VoiceOver on macOS (Safari)
- Enable:
System Settings → Accessibility → VoiceOver → On(or⌘+F5). - Navigate with
VOmodifier (Control+Optionby default):VO+→next item;VO+←previous.VO+Spaceactivate.VO+Uopens the rotor;VO+Htoggles per-heading navigation.
- Same flows + findings template as NVDA.
Keyboard-only without screen reader
- Disconnect / disable the mouse (or
System Settings → Accessibility → Pointer Control → Mouse Keysto fall back to keyboard pointer). - Login → Dashboard → Settings → Sign out — every action must be reachable.
- Critical:
Skip to main contentlink appears when you press Tab on a fresh page load; pressing Enter moves focus inside<main>.
6. Useful commands
pnpm verify:color-contrast # brand token contrast audit
pnpm test:a11y # vitest + axe-core suites
pnpm lint # incl. jsx-a11y/recommended (baseline-respecting)
pnpm verify:a11y-coverage # component + browser-level a11y coverage ratchet
pnpm verify:a11y-baseline # jsx-a11y baseline regression gate
pnpm verify:e2e-a11y # SKIP-safe playwright + axe-core route sweep
pnpm --filter @toolctrl/storybook dev # designer-facing live a11y feedback
pnpm verify:e2e-a11y exits successfully with a SKIP message when Playwright is
not installed or no target URL is reachable. Staging/CI lanes that must prove the
browser sweep should set REQUIRE_E2E_A11Y=1; that converts missing Playwright
or an unreachable E2E_BASE_URL into a failing gate. Authenticated browser specs
require E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD; set REQUIRE_AUTH_E2E=1
when those specs must fail instead of silently skipping without credentials.
Set REQUIRE_E2E_READINESS=1 for the non-mutating staging/readiness lane. That
requires tenant credentials plus E2E_TENANT_DASHBOARD_PATH,
E2E_TENANT_SUBSCRIPTION_PATH, API overview/keys/docs/logs paths,
E2E_TENANT_POLICIES_PATH, E2E_PLUGIN_SETTINGS_PATH,
E2E_WORKFLOW_BUILDER_PATH, E2E_WORKFLOW_RUN_API_PATH,
E2E_WORKFLOW_RUN_MANUAL_PATH, E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH, and
PLUGIN_BINARY_BASE_PATH, and turns
missing inputs or unavailable 400/403/404/login responses into failures.
Scheduled GitHub/Gitea readiness workflows run this contract against staging
with E2E_BROWSER_MATRIX=1, E2E_A11Y_STRICT=1, and
E2E_ALLOW_WORKFLOW_RUN=1.
Stripe/provider-live paths and mutating account/workflow actions stay separately
opt-in through their own env vars. Set SKIP_E2E_A11Y=1 when a local or CI lane
should explicitly bypass the browser sweep.