243 lines
14 KiB
Markdown
243 lines
14 KiB
Markdown
# 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 `<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-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 `<label>`s were associated with their controls via
|
|
`htmlFor`+`id` or 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, and `ConfirmModal`
|
|
inputs gained an `aria-label`; genuine div-buttons became `<button>`; rows
|
|
with nested interactive children gained `role="button"` + `tabIndex` +
|
|
`onKeyDown`; dead `onClick={e => e.preventDefault()}` handlers on plain
|
|
divs were removed; the `HoverEffect` `<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 the `SiteTypeCard` wrapper — keep a justified inline
|
|
`eslint-disable` because 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-valid` and
|
|
`jsx-a11y/no-noninteractive-tabindex` reached **0** reproduced
|
|
violations and were promoted back to `error`. 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-caption` reached **0** violations
|
|
and was promoted back to `error`. `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-autofocus` dropped from **86** to **81**
|
|
violations. `ButtonPrimary` and `LinkOrAhref` now preserve their
|
|
`autoFocus` caller contract through programmatic focus instead of passing
|
|
`autoFocus` to 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 |
|
|
| - | -------- | ---------------------------------------------------------------------- | --------------------------------------- | ---------------- |
|
|
| ~~1~~ | ~~P0~~ | ~~`pagination.tsx`~~ | ~~`anchor-has-content`~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
|
|
| ~~2~~ | ~~P0~~ | ~~`stepper.tsx`~~ | ~~`heading-has-content`~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
|
|
| ~~3~~ | ~~P1~~ | ~~`apps/web/app/components/**`~~ | ~~`label-has-associated-control` (15x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
|
|
| ~~4~~ | ~~P1~~ | ~~Custom div-buttons in editors~~ | ~~`click-events-have-key-events` (13x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
|
|
| ~~5~~ | ~~P1~~ | ~~Same set~~ | ~~`no-static-element-interactions` (11x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
|
|
| ~~6~~ | ~~P1~~ | ~~List-row click handlers~~ | ~~`no-noninteractive-element-interactions` (2x)~~ | ✅ Fixed 2026-05-20 — see promotion changelog. |
|
|
| ~~7~~ | ~~P2~~ | ~~Marketing demo videos~~ | ~~`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. |
|
|
| ~~9~~ | ~~P2~~ | ~~Single tabindex container~~ | ~~`no-noninteractive-tabindex`~~ | ✅ Promoted 2026-05-25 — stale baseline entry reproduced as 0. |
|
|
| ~~10~~| ~~P2~~ | ~~`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](https://github.com/toolctrl-labs/boilerplate/issues) with
|
|
label `area:accessibility`.
|
|
|
|
## 4. Adding a new component with a11y coverage from day-1
|
|
|
|
```tsx
|
|
// packages/ui-app/src/MyWidget.tsx
|
|
export function MyWidget({ label }: { label: string }) {
|
|
return <button aria-label={label}>...</button>;
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// 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](https://www.deque.com/axe/#what-can-axe-catch). The remaining
|
|
~43 % needs an actual screen reader.
|
|
|
|
### NVDA on Windows (Chrome)
|
|
|
|
1. Install [NVDA](https://www.nvaccess.org/download/) (free, open-source).
|
|
2. Start NVDA before opening Chrome (`Insert + N` opens the menu).
|
|
3. Navigate the staging site keyboard-only:
|
|
- `Tab` advances focus, `Shift+Tab` reverses.
|
|
- `H` jumps between headings; `K` between links; `F` between form fields.
|
|
- `Insert + F7` opens the elements list (headings/links/landmarks).
|
|
4. Validate flows: Login, Magic-Link, CRM contact create, DSGVO export.
|
|
5. Capture findings in `docs/accessibility/screen-reader-smoke-tests-<date>.md`
|
|
(one section per flow; format: `Pass / Findings`).
|
|
|
|
### VoiceOver on macOS (Safari)
|
|
|
|
1. Enable: `System Settings → Accessibility → VoiceOver → On` (or `⌘+F5`).
|
|
2. Navigate with `VO` modifier (`Control+Option` by default):
|
|
- `VO+→` next item; `VO+←` previous.
|
|
- `VO+Space` activate.
|
|
- `VO+U` opens the rotor; `VO+H` toggles per-heading navigation.
|
|
3. Same flows + findings template as NVDA.
|
|
|
|
### Keyboard-only without screen reader
|
|
|
|
1. Disconnect / disable the mouse (or `System Settings → Accessibility →
|
|
Pointer Control → Mouse Keys` to fall back to keyboard pointer).
|
|
2. Login → Dashboard → Settings → Sign out — every action must be reachable.
|
|
3. Critical: `Skip to main content` link appears when you press Tab on a
|
|
fresh page load; pressing Enter moves focus inside `<main>`.
|
|
|
|
## 6. Useful commands
|
|
|
|
```bash
|
|
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.
|