Files
HiveGrid-Wireframes/hivegrid-cloud/docs/contributing/wcag-audit.md
T

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:

  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 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 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)

  1. Install NVDA (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

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.