# WCAG 2.1 AA Accessibility — Implementation Plan

**Spec:** docs/specs/2026-05-31-wcag-accessibility.md  ·  **Slug:** wcag-accessibility  ·  **Wave:** 3
**Depends on:** dark-light-theme, foundation-design-system

## Goal
Establish WCAG 2.1 Level AA compliance primitives and guardrails across every tenant- and customer-facing surface: a global CSS layer (`:focus-visible`, skip link, reduced-motion), a reusable `SkipLink` component, reduced-motion static fallbacks for animated primitives (Spinner, Skeleton, Toast), live-region wiring on the toast system, an automated `@axe-core/playwright` E2E gate (zero serious/critical violations to merge), and a governance artifact (the per-module `## Accessibility` checklist). This spec defines standards and shared infrastructure — it introduces **no database tables**.

## Architecture
This spec ships exclusively into the shared UI package and the app E2E harness; it consumes color and z-index tokens already owned by `dark-light-theme` (spec 114) and primitive components already owned by `foundation-design-system` (spec 2). It does **not** redefine any color or z-index token.

- **Token CSS** lands in `packages/ui/src/tokens/index.css` (the file spec 114 + spec 2 already establish). Three new global blocks are appended: `:focus-visible` outline, `.skip-link` class, and the `prefers-reduced-motion` `@media` block. All three reference existing tokens: `var(--accent)`, `var(--ink-on-accent)`, `var(--radius)`, `var(--z-toast)` (= 400, defined in spec 114).
- **`SkipLink`** is a genuinely new primitive (not in the locked interface sheet) exported from `packages/ui`. It renders `<a href="#main-content" class="skip-link">`. The `<main id="main-content">` target and the requirement that `SkipLink` be the first focusable element are owned by `app-shell` (same wave, NOT a dependency of this spec) — this plan ships the component + CSS and documents the integration contract; it must not import from or depend on app-shell.
- **Reduced-motion fallbacks** are real edits to existing design-system primitives: `Spinner` (static percentage/label fallback), `Skeleton` (freeze pulse at first frame), `Toast`/`Toaster` (appear instantly at final position). These primitives already exist (`Spinner`, `Skeleton`, `Toast`, `Toaster` in the locked sheet).
- **Live-region toasts** are a configuration task on the existing `Toaster` (Sonner): success/info → `role="status"` `aria-live="polite"`; error → `role="alert"` `aria-live="assertive"` `aria-atomic="true"`.
- **Form label/error a11y** is already satisfied upstream: `FormField` (design-system spec lines 425-449) wires `aria-invalid`, `aria-describedby`, and a `<p role="alert">` error node. This plan adds an E2E assertion that verifies it, not a re-implementation.
- **Modal focus trap** is already provided by Radix Dialog (design-system "Radix UI via shadcn/ui" foundation). This plan adds an E2E assertion (Tab cycles within, Escape closes, focus returns to trigger), not a custom `useFocusTrap`.
- **axe-core E2E harness** lands in `apps/zync-app` (Playwright is the declared E2E runner for `zync-app` per foundation-monorepo). A shared helper `expectNoA11yViolations(page)` wraps `@axe-core/playwright` with the WCAG 2.1 AA ruleset and fails on any `serious`/`critical` impact.
- **Governance artifact:** the `## Accessibility` markdown checklist template is added to the repo's spec-authoring docs so every future module spec embeds it.

## Tech Stack
- **Package:** `@zync/ui` (`packages/ui`) — CSS tokens, `SkipLink`, primitive reduced-motion edits, toast live-region config.
- **App:** `apps/zync-app` — Playwright E2E suite + axe helper.
- **Config:** `packages/config` — shared ESLint flat config (focus-suppression review-gate note only; no new named lint rule invented).
- **Libraries:** `@axe-core/playwright` (new dev dependency), Playwright (already present, foundation-monorepo), Radix UI (already present), Sonner (already present, via `Toast`/`Toaster`).
- **Cloudflare bindings:** none — this spec is pure UI/CSS/test infrastructure.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1, 2 | `packages/ui/src/tokens/index.css` | Tasks 1 & 2 edit the same file — run sequentially within, but A is parallel to B/C |
| B | 3 | `packages/ui/src/primitives/skip-link.tsx`, `packages/ui/src/index.ts` | Yes (parallel to A, C) |
| C | 4, 5 | `packages/ui/src/feedback/spinner.tsx`, `skeleton.tsx`, `toast.tsx` | Yes (parallel to A, B) |
| D | 6, 7 | `apps/zync-app/tests/**`, `apps/zync-app/playwright.config.ts`, `package.json` | Blocked by 1–5 (asserts their behavior) |
| E | 8 | `docs/specs/_templates/accessibility-checklist.md`, `packages/config/eslint.config.mjs` | Yes (parallel to D) |

## Tasks

### Task 1: Global focus-visible and skip-link CSS
**Blocks:** 3, 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/ui/src/tokens/index.css`
**Steps:**
- [ ] Append a `:focus-visible` rule that draws a 2px accent outline with 2px offset and the global radius, using existing tokens only.
- [ ] Append the `.skip-link` class (off-screen by default, revealed on `:focus`) using `var(--accent)`, `var(--ink-on-accent)`, and `var(--z-toast)` (z-index 400 — already defined on `:root` in spec 114; reference it, do not redefine).
- [ ] Add an inline comment near the existing `--ink-faint` usage documenting that `--ink-faint` is NOT contrast-compliant for readable text (decorative/disabled/placeholder only).
- [ ] Do NOT add `outline: none` anywhere global; if any reset strips outline, it must pair with a `:focus-visible` restore.
**Schema / Interfaces:**
```css
/* Visible keyboard focus — WCAG 2.1 SC 2.4.7. Never suppress globally. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius);
}

/* Skip link — first focusable element on every page (rendered by app-shell).
   Visible only on focus. z-index uses --z-toast (=400), owned by spec 114. */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: var(--accent);
  color: var(--ink-on-accent);
  padding: 8px 16px;
  z-index: var(--z-toast);
  transition: top 150ms;
}
.skip-link:focus {
  top: 0;
}

/* --ink-faint is NOT WCAG-compliant for readable text (contrast < 4.5:1).
   Use only for decorative content, disabled controls, and placeholders. */
```
**Acceptance:**
- [ ] `:focus-visible` outline renders on keyboard focus of any interactive element in both `.dark` and `:root` themes.
- [ ] `.skip-link` is visually off-screen until focused, then snaps to `top: 0`.
- [ ] No global `outline: none` without a `:focus-visible` alternative exists in `packages/ui`.

### Task 2: Reduced-motion global media block
**Blocks:** 4, 6  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/ui/src/tokens/index.css`
**Steps:**
- [ ] Append the `prefers-reduced-motion: reduce` `@media` block that neutralizes animation duration/iteration, transition duration, and scroll behavior across all elements and pseudo-elements.
- [ ] Place it after the token definitions so it overrides Tailwind `transition-*` / `animate-*` utilities.
- [ ] Add a comment noting that three primitives require a *static fallback* (not just speed reduction): Spinner, Skeleton, Toast — handled in Tasks 4 and 5.
**Schema / Interfaces:**
```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
```
**Acceptance:**
- [ ] With OS "reduce motion" enabled, all CSS animations/transitions are effectively instant.
- [ ] Tailwind `animate-*` / `transition-*` utilities are silenced under reduced motion without per-component changes.

### Task 3: SkipLink component
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/primitives/skip-link.tsx`
- Modify: `packages/ui/src/index.ts` (barrel export)
**Steps:**
- [ ] Implement `SkipLink` as an anchor to `#main-content` carrying the `skip-link` class (from Task 1).
- [ ] Accept an optional `targetId` (default `"main-content"`) and optional `label` (default `"Skip to main content"`) for i18n callers.
- [ ] Export `SkipLink` and `SkipLinkProps` from the package barrel.
- [ ] Document in a top-of-file comment that the consuming app (app-shell) must render `<SkipLink />` as the FIRST child of the layout and place `id="main-content"` on its `<main>` — this component does not own that wiring.
**Schema / Interfaces:**
```tsx
// packages/ui/src/primitives/skip-link.tsx
export interface SkipLinkProps {
  /** id of the <main> landmark to jump to. Default: "main-content". */
  targetId?: string
  /** Visible/announced label. Default: "Skip to main content". */
  label?: string
}

export function SkipLink({
  targetId = 'main-content',
  label = 'Skip to main content',
}: SkipLinkProps): JSX.Element {
  return (
    <a href={`#${targetId}`} className="skip-link">
      {label}
    </a>
  )
}
```
**Acceptance:**
- [ ] `SkipLink` is exported from `@zync/ui`.
- [ ] Rendering `<SkipLink />` and pressing Tab from page load focuses it; activating it moves focus to the element with `id="main-content"`.
- [ ] Component is theme-agnostic (styling comes entirely from the `.skip-link` class).

### Task 4: Reduced-motion static fallbacks for Spinner and Skeleton
**Blocks:** 6  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/ui/src/feedback/spinner.tsx`
- Modify: `packages/ui/src/primitives/skeleton.tsx`
**Steps:**
- [ ] `Spinner`: when `prefers-reduced-motion: reduce` is active, render a static textual fallback instead of a rotating glyph. If an optional `percent` prop is provided, render that percentage as text; otherwise render a visually-static "Loading…" label. Detect via a `useReducedMotion` hook (CSS-only neutralization from Task 2 is insufficient because a spinner needs a *static replacement*, not a slowed animation).
- [ ] Add a small `useReducedMotion()` hook (reads `matchMedia('(prefers-reduced-motion: reduce)')`, subscribes to changes) co-located in `packages/ui/src/hooks/use-reduced-motion.ts`; export it from the barrel.
- [ ] `Skeleton`: under reduced motion, freeze the pulse — render the skeleton at its first/base frame with no shimmer animation class.
- [ ] Ensure the loading container still exposes `aria-busy="true"` (per spec ARIA Loading States) regardless of motion preference.
**Schema / Interfaces:**
```tsx
// packages/ui/src/hooks/use-reduced-motion.ts
export function useReducedMotion(): boolean
// returns true when (prefers-reduced-motion: reduce) matches; SSR-safe (false on server)

// packages/ui/src/feedback/spinner.tsx — added prop
interface SpinnerProps {
  size?: 'sm' | 'md' | 'lg'
  /** Optional progress; when reduced-motion is active, shown as static "{percent}%" text. */
  percent?: number
  label?: string   // default "Loading…"
}
```
**Acceptance:**
- [ ] With reduced motion enabled, `Spinner` shows static text (percentage if provided, else "Loading…") and no rotation.
- [ ] With reduced motion enabled, `Skeleton` shows no pulse/shimmer (frozen first frame).
- [ ] Loading containers report `aria-busy="true"`.

### Task 5: Toast live regions + reduced-motion instant placement
**Blocks:** 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/ui/src/feedback/toast.tsx`
**Steps:**
- [ ] Configure `Toaster` (Sonner) so success/info toasts render with `role="status"` `aria-live="polite"` and error toasts with `role="alert"` `aria-live="assertive"`; set `aria-atomic="true"` on the announcement node.
- [ ] Ensure toasts are announced without shifting focus (no `autofocus`, no programmatic focus on toast mount).
- [ ] Under `prefers-reduced-motion: reduce`, toasts must appear instantly at their final position (no slide-in). Use the `useReducedMotion` hook from Task 4 to disable the entrance animation/offset, or pass Sonner an option that disables the slide transition when reduced motion is active.
- [ ] Do NOT apply `aria-live="assertive"` to non-error, frequently-updating content (counters) — keep assertive scoped to error toasts only.
**Schema / Interfaces:**
```tsx
// Live-region contract enforced by the Toaster:
// success | info  -> role="status"  aria-live="polite"   aria-atomic="true"
// error          -> role="alert"   aria-live="assertive" aria-atomic="true"
```
**Acceptance:**
- [ ] A success toast is announced politely by a screen reader without moving focus.
- [ ] An error toast is announced assertively.
- [ ] With reduced motion enabled, toasts appear at final position with no slide-in.

### Task 6: axe-core E2E harness + shared assertion helper
**Blocks:** 7  ·  **Blocked by:** 1, 2, 3, 4, 5
**Files:**
- Create: `apps/zync-app/tests/a11y/axe-helper.ts`
- Create: `apps/zync-app/tests/a11y/a11y.spec.ts`
- Modify: `apps/zync-app/package.json` (add `@axe-core/playwright` dev dependency)
- Modify: `apps/zync-app/playwright.config.ts` (register the a11y project/suite if not already covered)
**Steps:**
- [ ] Add `@axe-core/playwright` as a dev dependency in `apps/zync-app`.
- [ ] Implement `expectNoA11yViolations(page)` — runs `AxeBuilder` against the page with the WCAG 2.1 AA tag set (`wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`), then asserts there are zero violations of impact `serious` or `critical`. Lower-impact (`minor`/`moderate`) violations are reported (logged) but do not fail.
- [ ] Write E2E cases that load representative app routes and call `expectNoA11yViolations` in **both** `.dark` and light themes (toggle theme via the `ui_theme` cookie / `document.documentElement.classList`).
- [ ] Add focused assertions for spec-mandated behaviors that axe alone cannot fully verify:
  - SkipLink is the first focusable element and jumps focus to `#main-content`.
  - Modal focus trap: opening a Radix `Dialog` moves focus inside; Tab cycles within; Escape closes; focus returns to the trigger.
  - Form error association: a `FormField` validation error sets `aria-invalid="true"` and links `aria-describedby` to a `role="alert"` node.
  - Toast: triggering a success toast produces a `role="status"` live region; an error toast produces `role="alert"`.
**Schema / Interfaces:**
```ts
// apps/zync-app/tests/a11y/axe-helper.ts
import AxeBuilder from '@axe-core/playwright'
import { expect, type Page } from '@playwright/test'

const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
const BLOCKING_IMPACTS = new Set(['serious', 'critical'])

export async function expectNoA11yViolations(page: Page): Promise<void> {
  const results = await new AxeBuilder({ page }).withTags(WCAG_AA_TAGS).analyze()
  const blocking = results.violations.filter(
    (v) => v.impact != null && BLOCKING_IMPACTS.has(v.impact),
  )
  if (results.violations.length > 0) {
    console.warn(`axe: ${results.violations.length} total a11y issue(s)`,
      results.violations.map((v) => `${v.impact}:${v.id}`))
  }
  expect(blocking, JSON.stringify(blocking.map((v) => v.id))).toHaveLength(0)
}
```
**Acceptance:**
- [ ] `expectNoA11yViolations` fails the test when any `serious`/`critical` WCAG 2.1 AA violation is present.
- [ ] A11y suite runs representative routes in both themes and passes.
- [ ] SkipLink, modal focus trap, form-error association, and toast live-region assertions all pass.

### Task 7: CI gate — block merge on serious/critical a11y violations
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/package.json` (add `test:a11y` script)
- Modify: CI workflow that runs `pnpm turbo test` / E2E (wire the a11y suite into the existing E2E job)
**Steps:**
- [ ] Add a `test:a11y` script invoking the Playwright a11y project.
- [ ] Ensure the a11y suite is part of the E2E stage that runs against the preview URL (foundation-monorepo CI step 4), so a failing axe assertion blocks merge.
- [ ] Document in the workflow that zero `serious`/`critical` violations is a required check.
**Acceptance:**
- [ ] A PR introducing a `serious`/`critical` WCAG 2.1 AA violation fails CI and cannot merge.
- [ ] The a11y suite is reachable via `pnpm --filter zync-app test:a11y`.

### Task 8: Module A11y checklist template + focus-suppression review gate
**Blocks:** —  ·  **Blocked by:** —
**Files:**
- Create: `docs/specs/_templates/accessibility-checklist.md`
- Modify: `packages/config/eslint.config.mjs` (comment-only review-gate note; no new named rule invented)
**Steps:**
- [ ] Write the canonical `## Accessibility` markdown block that every new UI module spec must embed (transcribe verbatim from the spec, including the additional patterns).
- [ ] Note that module specs omitting this section are incomplete and may not be implemented until it is added.
- [ ] In the shared ESLint config, add a documenting comment that suppressing focus via global `outline: none` without a `:focus-visible` alternative is a **code-review gate** (the spec frames it as a violation, not a named linter like `no-hardcoded-colors`); do not fabricate a new named rule.
**Schema / Interfaces:**
```markdown
## Accessibility

- [ ] All interactive elements keyboard-reachable (Tab/Arrow)
- [ ] Focus visible on all interactive elements
- [ ] ARIA labels on icon-only buttons
- [ ] Form errors associated via `aria-describedby` (see design-system spec)
- [ ] Reduced-motion safe (no essential animations)
- [ ] Color contrast checked in both dark/light themes
- [ ] Screen reader tested or marked "pending SR test" with issue reference
- [ ] Drag-and-drop (if present): keyboard sensor configured (see tasks-board-engine)
- [ ] Charts (if present): aria-label + data table fallback (see reports-analytics)
- [ ] Tiptap editors (if present): Content Security pattern applied (see kb-article-editor)

**Additional patterns:**
- [ ] Charts have `role="figure"` + visually-hidden data table alternative
- [ ] Rich text editors (Tiptap): `role="textbox"` `aria-multiline="true"`, toolbar has `role="toolbar"`
- [ ] File upload zones: `role="button"` `tabindex="0"`, keyboard operable, progress announced via `role="progressbar"`
- [ ] Drag-and-drop: `KeyboardSensor` configured, `DndContext` `announcements` prop defined
- [ ] Live-updating counters: `aria-label` updated periodically; do NOT use `aria-live="assertive"` on counters
- [ ] Data tables with sortable columns: `aria-sort="ascending|descending|none"` on headers; sort change announced via `aria-live="polite"`
- [ ] Date pickers: full keyboard navigation (Arrow/Page/Home/End), `aria-label` on each day cell
```
**Acceptance:**
- [ ] `docs/specs/_templates/accessibility-checklist.md` contains the full checklist and the "incomplete spec" enforcement note.
- [ ] The ESLint config carries the focus-suppression review-gate comment; no invented named rule is added.
