This document describes the automated accessibility gate in CI, what it covers, and the manual checks it cannot replace.
Automated coverage lives under e2e/accessibility/ and runs with
Playwright (npm run test:e2e:a11y), gated in CI by the accessibility
job in .github/workflows/ci.yml.
Every route currently shipped in app/ is scanned (see
e2e/accessibility/routes.ts, the single place to add a new route):
/— home/how-it-works/faq/developers/issuers/privacy/terms/status/proofs/create— the closest current analog to an authenticated flow: wallet connect (Freighter) + income payment selection + proof creation/verify— public proof verification by ID/verify/credential— public proof verification by uploaded credential JSON- a 404 (error page)
Not yet covered: dedicated "payments", "proof history", and "proof
detail" pages do not exist in the app yet (app/ has no such routes as of
this PR). They are intentionally left out of routes.ts rather than
faked. Once those pages ship, add one entry per route to
e2e/accessibility/routes.ts and they'll be scanned automatically by the
existing scans.spec.ts loop — no new harness work required.
e2e/accessibility/fixtures/axe.ts runs @axe-core/playwright with the
wcag2a, wcag2aa, wcag21a, and wcag21aa tag sets (covers missing
accessible names, invalid ARIA usage, landmark/heading structure,
color-contrast, label associations, and similar). A test fails only on
violations with critical or serious impact; minor/moderate
findings are still recorded in the attached JSON report (and the HTML
report Playwright produces in CI) but don't fail the build, so the gate
stays actionable rather than noisy. Every failure message names the route,
the axe rule id, the impact level, and the specific DOM node(s) involved.
e2e/accessibility/dynamic-states.spec.ts drives real interactions
(mocked wallet via fixtures/mock-freighter.ts, mocked API via
fixtures/mock-api.ts) to scan states beyond the initial HTML:
/proofs/create: wallet-connected state, payment-sync success, the in-progress "Creating signed minimum-income proof..." loading state, a failed proof-creation error state, and the completed success state./verifyand/verify/credential: the loading state, a valid-result success state, and validation-error states (empty input / invalid JSON).
e2e/accessibility/keyboard.spec.ts uses real page.keyboard automation
(not axe, which cannot verify actual tab order or key handling):
- Skip link: tabbing from a fresh page load reveals "Skip to main
content" as the first focus stop, and activating it moves focus to
#main-content. - Disclosures: the FAQ accordion opens/closes with Enter/Space,
aria-expandedtoggles correctly, and focus stays on the trigger. - Form error announcements: submitting invalid input on the verify,
verify-credential, and proof-creation forms moves focus to a
role="alert"region and the submitting control'saria-describedbypoints at it. - Focus restoration: clearing the FAQ search restores focus to the
search field, and disconnecting the wallet on
/proofs/createrestores focus to "Connect Freighter" — both fixed as part of this change, since neither previously restored focus and would otherwise drop keyboard users' focus to<body>.
No current subject (harness ready, not faked):
- Nav menu / dropdown:
components/layout/public-nav.tsxhas no dropdown or mobile-menu disclosure yet — nav links are simply hidden below themdbreakpoint with no mobile alternative. This is a real gap worth its own follow-up (mobile users currently have no way to reach nav links other than the logo), but adding a mobile menu is a UI change outside the scope of "enforce accessibility checks in CI." Flagged here so it isn't missed. - Dialogs/modals: none exist in the app yet. The moment one is added,
a focus-trap / focus-return test belongs in
keyboard.spec.tsnext to the disclosure and focus-restoration tests already there.
Every spec runs under two Playwright projects (playwright.config.ts):
Desktop Chrome (1280x800) and Mobile Chrome (Pixel 5 emulation), so
contrast, spacing, and interaction assertions are checked at both sizes.
fixtures/mock-api.tsintercepts EarnProof API calls (NEXT_PUBLIC_API_URL) withpage.routeand returns fixed JSON fixtures, so payment lists, proof IDs, hashes, and dates never vary between runs.fixtures/mock-freighter.tsanswers the Freighter extension'swindow.postMessageprotocol directly (the extension isn't installed in the Playwright browser), so wallet connect/sign can be exercised deterministically without a real Freighter install. See the comment in that file for the exact message protocol this reverse-engineers from@stellar/freighter-api.
Automation (axe + scripted keyboard interaction) cannot prove the following. Review these periodically — after any significant UI change, and at minimum before each release — using a real screen reader.
Run with at least one of NVDA (Windows/Firefox or Chrome), VoiceOver (macOS Safari), or JAWS (Windows).
- Announcement quality, not just presence. axe confirms an
accessible name exists; it can't judge whether the name is clear or
redundant. Navigate every scanned route by screen reader and confirm:
- Headings read in a sensible order and describe their section.
- Buttons/links announce their purpose without needing surrounding context (e.g. "Connect Freighter", not "Button").
- The FAQ accordion announces expanded/collapsed state and the question/answer relationship clearly when toggled.
- Reading order vs. DOM order. Confirm the order content is
announced in on
/proofs/create(wallet → payments → proof form → feedback) and/verify(form → privacy notice → result panel) matches the visual reading order at both viewport sizes, especially after the CSS grid reflows on mobile. - Live region behavior in practice.
aria-live="assertive"error regions andaria-live="polite"status regions are wired programmatically, but confirm by ear that:- The error is announced promptly without repeating itself.
- Status updates ("Requesting Freighter wallet access...", "Payments synced.") don't talk over each other or get skipped when they change quickly.
- Alt text semantics. The EarnProof logo
<Image>usesalt="EarnProof"— confirm this (and any future imagery) describes purpose, not just appearance, and that purely decorative graphics (the FAQ chevron icon, status badges) stayaria-hiddenand are correctly skipped. - Cognitive load / plain-language clarity. Read the proof-creation
copy, error messages, and privacy notices aloud. Confirm:
- Error messages describe what to do next, not just what failed.
- Technical terms (credential hash, wallet hash, classification values) have enough surrounding context for a first-time user.
- Zoom / reflow. Set browser zoom to 200% and confirm no content is clipped or requires horizontal scrolling on the scanned routes.
- Color contrast in context. axe's
color-contrastrule is included in the automated run, but spot-check text over gradients/borders (e.g. status badges, the cyan accent on dark backgrounds) visually, since axe can miss contrast issues on non-solid backgrounds.
Record findings from this checklist (route, issue, screen reader/browser
combo) as GitHub issues tagged accessibility so they can be triaged
against the automated gate above.
A manual WCAG 2.1 AA contrast audit of the status badge component
(StatusBadge in components/common/production-ui.tsx, used on /status
and throughout the proofs/verification flows) and the cyan accent colors
defined in app/globals.css was carried out using the WCAG 2.1
relative-luminance contrast formula
(the same algorithm @axe-core/playwright uses for its color-contrast
rule), computed against this app's actual rendered colors — the
--background: #020617 page background and the bg-white/[0.04] panel
surface most badges and bordered cards sit on. There is no
tailwind.config.ts in this repo; Tailwind v4's CSS-based @theme config
lives entirely in app/globals.css, so that file was the audit's only
source of truth for color values.
| Element | Before | After | Requirement | Result |
|---|---|---|---|---|
StatusBadge text (text-cyan-200) on any tone's translucent fill |
13.7–13.9:1 | unchanged | 4.5:1 (normal text) | Pass (no change needed) |
Decorative/structural borders using border-cyan-300/30 (badges, info panels, buttons, the skip link's focus-visible border) |
2.11:1 | 3.98:1 (border-cyan-300/50) |
3:1 (non-text UI component boundary, WCAG 1.4.11) | Failed → fixed |
Focus outline (outline-color: #22d3ee in app/globals.css, and outline-cyan-300 utility) |
11.16:1 | unchanged | 3:1 (focus indicators, WCAG 1.4.11) | Pass (no change needed) |
Secondary/tertiary body text (text-slate-500, matches --text-tertiary: #64748b) rendered directly as content (labels, "Hidden from verifiers" copy, disabled-input values, "Coming soon" text) |
3.07–4.24:1 depending on surface | 5.71–7.87:1 (text-slate-400) |
4.5:1 (normal text, WCAG 1.4.3) | Failed → fixed |
text-slate-500 on placeholder: attributes (form input placeholders)
was left unchanged: WCAG 1.4.3 applies to rendered text content, and
placeholder text is conventionally treated as a UI hint rather than
required reading — but it's worth a follow-up pass with a screen reader
per item 1 of the manual checklist above, since some users do rely on it.
border-cyan-300/30→border-cyan-300/50everywhere it draws a decorative or structural border (badges, bordered panels/buttons, the skip link), inapp/error.tsx,app/not-found.tsx,app/verify/[proofId]/page.tsx,app/faq/page.tsx,app/about/page.tsx,app/accessibility/page.tsx,app/proof-types/page.tsx,components/contact/contact-form.tsx,components/proofs/artifact-export.tsx,components/proofs/recurring-proof-confirmation.tsx,components/proofs/coverage-analysis-step.tsx,components/proofs/proof-confirmation.tsx,components/proofs/period-config-step.tsx,components/verification/verify-credential-form.tsx,components/common/skip-link.tsx,components/verification/verify-proof-form.tsx,components/common/network-badge.tsx, andcomponents/common/production-ui.tsx(theStatusBadgecomponent itself), plus the matching test assertion inapp/proof-types/__tests__/page.test.tsx.text-slate-500→text-slate-400for rendered text content (not placeholders) inapp/proof-types/page.tsx,components/proofs/payment-selection.tsx,components/proofs/proof-confirmation.tsx,components/proofs/artifact-export.tsx,components/proofs/wizard-steps.tsx,components/verification/verify-proof-form.tsx,components/verification/verify-credential-form.tsx, andcomponents/common/external-link.tsx.slate-400was already the established convention for this same purpose elsewhere inapp/proof-types/page.tsx, so this also removes an inconsistency between two shades doing the same job.
The same 2:1–2.1:1 non-text-contrast shortfall exists on the /30-opacity
borders used for amber/emerald/rose status alert boxes (warning/error/
success panels) across roughly twenty files, e.g. border-amber-300/30,
border-emerald-300/30, border-rose-300/30 in
components/verification/verification-panel.tsx,
components/organizations/organization-list.tsx,
components/developers/api-key-list.tsx, and others. This audit's scope
was status badges and the cyan accent per the originating issue; the
amber/emerald/rose alert borders should get the same /30 → /50
treatment in a dedicated follow-up so the whole alert-box family is
consistent, rather than folding an unrelated ~20-file change into this
pass.