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
| Piece | Status | Notes |
|---|---|---|
| Axe-core CI gate | shipped, advisory | An 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 validation | shipped, blocking | Branding 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 announcer | shipped | useA11yAnnouncer Zustand store + <A11yLiveRegion /> mounted at the admin shell. Two static regions (polite + assertive) with the clear→write→clear drain pattern. |
| Modal focus-trap primitive | shipped | <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 sweep | planned | Every controlled input binds the attribute to its current validation state. |
| VPAT generator | planned | In-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 whendismissOnBackdropClick={false}. - Focus restoration — focus returns to whatever element opened
the dialog, captured via
useLayoutEffectbefore 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-describedbywiring 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.