Docs

WCAG 2.1 AA enforced in CI, theme contrast validated on save, focus-trap modal primitive — the architectural pieces that clear institutional procurement.

Accessibility

FastYoke targets WCAG 2.1 Level AA as a platform invariant — not a per-tenant opt-in. The goal is a clean Voluntary Product Accessibility Template (VPAT) for institutional and enterprise procurement, with compliance engineered into the CI pipeline and the theme-save path rather than retrofitted.

This section documents what's live today and what's planned.

What's live today

PieceStatusNotes
Axe-core CI gateshipped, advisoryAn axe-core CI gate runs Playwright + @axe-core/playwright across 12 routes on every PR. Critical/serious WCAG 2.1 AA violations surface in the PR comment but don't block merge yet — the VPAT generator flips the gate when shipped.
Theme contrast validationshipped, blockingBranding save returns a structured 422 when primary_text↔primary or sidebar_text↔sidebar_bg falls below 4.5:1. Auto-suggests the nearest compliant shade. Bypass writes an audit row.
ARIA announcershippeduseA11yAnnouncer Zustand store + <A11yLiveRegion /> mounted at the admin shell. Two static regions (polite + assertive) with the clear→write→clear drain pattern.
Modal focus-trap primitiveshipped<Modal /> wraps @radix-ui/react-dialog. Focus trap, Escape dismissal, focus restoration, aria-labelledby / aria-describedby wiring all baked in. <SkipToContent /> mounted as the first tabbable element of the admin shell.
aria-invalid sweepplannedEvery controlled input binds the attribute to its current validation state.
VPAT generatorplannedIn-CI generator that turns the rule-coverage matrix into a committed VPAT document.

Where the gate runs

The CI workflow boots a production build of the SPA via vite preview, runs Playwright + @axe-core/playwright against a fixed cycle list, and writes a JSON report. It currently scans:

  • The marketing landing (/)
  • The docs index (/docs)
  • The login page (/login)
  • Every primary admin route under /admin/* — Dashboard, Workflows, Jobs, Entities, Pages, Forms, Settings, Branding, Plan & Billing.

Admin routes are exercised end-to-end in CI against recorded fixtures, so accessibility checks run on the real rendered UI. This keeps the audit job under two minutes and removes a backend failure mode from the gate.

Allowlist policy

Known-flake violations live in our published axe allowlist with a hard 90-day expiry. The harness rejects expired entries on load — there's no mute-and-forget path. Add an entry only when the violation is genuinely outside our DOM (e.g. third- party iframes) and pair it with a tracking ticket.

Theme contrast on save

Branding settings (/admin/settings/branding) validate role-paired colors before persisting. The two pairs we check are primary_text ↔ primary (button-text-on-button) and sidebar_text ↔ sidebar_bg (sidebar nav). Both must clear 4.5:1 per WCAG 1.4.3.

A failing pair returns a 422 with the structured body the UI turns into the per-pair Use {hex} buttons:

{
  "error_code": "theme_contrast_failed",
  "message": "Theme contrast does not meet WCAG 2.1 AA",
  "violations": [
    {
      "fg_var": "primary_text",
      "bg_var": "primary",
      "fg_hex": "#ffffff",
      "bg_hex": "#3b82f6",
      "computed_ratio": 3.68,
      "target_ratio": 4.5,
      "suggested_fg": "#1d4ed8"
    }
  ]
}

The "Bypass for staging" affordance writes an append-only row to theme_contrast_rejections (platform DB, single migration 0062) so the compliance signal isn't lost when an operator chooses to ship a non-compliant tenant for a staging env.

The Modal primitive

Every modal in the SPA mounts through the shared modal primitive. It owns:

  • Tab cycling — focus stays inside the dialog while open.
  • Escape dismissal — calls onOpenChange(false), even when dismissOnBackdropClick={false}.
  • Focus restoration — focus returns to whatever element opened the dialog, captured via useLayoutEffect before Radix's focus scope takes over (Radix's built-in restoration only works with <Dialog.Trigger>; we ship a fully-controlled API).
  • Initial focus — Radix moves focus to the first focusable child by default; override with initialFocusRef.
  • aria-labelledby + aria-describedby wiring via <Modal.Description>.
import { Modal } from '@/components/Modal';

<Modal
  open={isOpen}
  onOpenChange={setIsOpen}
  title="Confirm"
>
  <Modal.Description>
    Are you sure you want to proceed?
  </Modal.Description>
  <div className="flex justify-end gap-2">
    <button onClick={() => setIsOpen(false)}>Cancel</button>
    <button onClick={handleConfirm}>OK</button>
  </div>
</Modal>

Hand-rolled <div className="fixed inset-0"> modals are forbidden for new code. The VPAT generator release will flip the axe gate to assert "no top-level dialog DOM outside the primitive"; existing 16 hand-rolled sites migrate opportunistically.

Announcer for non-visual signals

Surfaces with no in-place error UI (optimistic-UI rollbacks, async-result toasts, validation announcements) push messages through useA11yAnnouncer.announce():

import { useA11yAnnouncer } from '@/store/useA11yAnnouncer';

const announce = useA11yAnnouncer((s) => s.announce);

await transitionJob(jobId);
// On success or error...
announce(`Job ${jobId} transitioned to ${nextState}`);
// or
announce('Card declined — please try a different payment method', 'assertive');

Two regions are mounted at the admin shell root: one aria-live="polite", one aria-live="assertive". The store queues messages with a 100ms write delay (so screen readers catch the DOM mutation reliably) and a 3-second hold (so the same text re-announces cleanly).

VPAT readiness

The VPAT generator (planned) will produce the published VPAT document from the live axe rule-coverage matrix plus a hand-curated attestation file. Until then, the per-route CI report is the authoritative source — every main-branch run uploads the JSON artifact, and the accessibility baseline records the burndown target.