This document describes the automated accessibility testing suite for Stellar Save, how to run it, and known issues.
Note: Automated tools catch roughly 30–40% of WCAG issues. Full compliance requires manual testing with assistive technologies (screen readers, keyboard-only navigation, high-contrast mode). See Manual Testing below.
| Tool | Purpose | Standard |
|---|---|---|
| jest-axe | axe-core violations in Vitest component tests | WCAG 2.1 AA |
| Pa11y CI | Full-page scans against the built app | WCAG 2.1 AA |
| Lighthouse CI | Accessibility score audit per build | Lighthouse accessibility rules |
| @axe-core/react | Runtime dev-mode warnings in the browser | WCAG 2.1 AA |
cd frontend
npm run test:a11y # run once
npm run test:a11y:ci # verbose output for CIThese tests live in frontend/src/test/a11y.test.tsx and cover:
- UI primitives:
Input,Button,Tabs,Pagination,SearchBar,Spinner - Feature components:
WalletButton,CreateGroupForm,JoinGroupButton,ContributeButton - Pages:
LandingPage,NotFoundPage,ErrorPage,SettingsPage - Keyboard navigation: Tab order, arrow-key navigation in Tabs, Enter/Space on buttons, Escape to close modals
- Screen reader attributes:
role,aria-label,aria-labelledby,aria-describedby,aria-live,aria-invalid,aria-selected,aria-current
cd frontend
npm run build
npm run preview & # starts on http://localhost:4173
npm run test:a11y:pa11y # scans all pages defined in .pa11yrc.jsonScreenshots are saved to frontend/pa11y-screenshots/.
cd frontend
npm run build
npm run lhci # runs Lighthouse CI including accessibility auditThe accessibility.yml workflow runs on every push and pull request to main and develop:
axe-unit ──┐
├──► a11y-gate (blocks merge on failure)
pa11y ──┤
│
lighthouse ──┘
- axe-unit — Vitest + jest-axe component tests. Blocks the gate on failure.
- pa11y — Full-page WCAG 2.1 AA scan. Blocks the gate on failure.
- lighthouse-a11y — Lighthouse accessibility score ≥ 90. Posts a comment on PRs. Does not block the gate (score is a warning signal).
| Criterion | Description | How Tested |
|---|---|---|
| 1.1.1 Non-text Content | Images have alt text | axe, Pa11y |
| 1.3.1 Info and Relationships | Semantic HTML, labels, roles | axe, Pa11y |
| 1.3.3 Sensory Characteristics | Instructions don't rely on shape/color alone | axe |
| 1.4.3 Contrast (Minimum) | Text contrast ≥ 4.5:1 | axe, Pa11y, Lighthouse |
| 2.1.1 Keyboard | All functionality via keyboard | Vitest keyboard tests |
| 2.1.2 No Keyboard Trap | Focus can always move away | Vitest keyboard tests |
| 2.4.3 Focus Order | Logical tab order | Vitest tab tests |
| 2.4.6 Headings and Labels | Descriptive headings and labels | axe, Pa11y |
| 2.4.7 Focus Visible | Keyboard focus is visible | axe |
| 3.3.1 Error Identification | Errors identified in text | axe (aria-invalid, role=alert) |
| 3.3.2 Labels or Instructions | Form inputs have labels | axe, Pa11y |
| 4.1.2 Name, Role, Value | ARIA attributes correct | axe, Pa11y |
| 4.1.3 Status Messages | Live regions for dynamic content | axe (aria-live) |
Input— label association,aria-invalid,aria-describedby,role="alert"on errorsButton— keyboard focus, disabled state, loading spinneraria-hiddenTabs—role="tablist",role="tab",role="tabpanel", arrow-key navigation,aria-selectedPagination—aria-labelon prev/next,aria-current="page", labelled page-size selectSearchBar—role="searchbox",aria-label,role="listbox"for suggestions,aria-hiddenon decorative iconSpinner—role="status",aria-label,FullPageLoaderrole="alert"WalletButton— keyboard open/close, accessible text when connectedCreateGroupForm— multi-step form,role="progressbar"witharia-valuenow/min/max, validationrole="alert", Tab orderJoinGroupButton— all states (eligible, confirmation, disabled)ContributeButton— modal heading, keyboard dismiss with EscapeLandingPage— skip link,role="banner",role="contentinfo",role="navigation"with labels, section headingsNotFoundPage—h1heading, image alt text, keyboard-focusable buttonsErrorPage— heading, image alt text, keyboard-focusable buttonsSettingsPage— radio group, sharednameattribute, keyboard navigation
Automated tools cannot catch everything. The following should be tested manually before each release:
| Screen Reader | Browser | Platform |
|---|---|---|
| NVDA | Firefox, Chrome | Windows |
| JAWS | Chrome, Edge | Windows |
| VoiceOver | Safari | macOS / iOS |
| TalkBack | Chrome | Android |
Key flows to test:
- Landing page — read all sections in order, navigate by headings
- Connect wallet — announce connection state changes
- Browse groups — filter and search results announced
- Create group — multi-step form, step progress announced, validation errors announced
- Join group — confirmation dialog announced, focus managed
- Contribute — modal focus trap, success/error announced
- Settings — theme and language changes announced
- Dashboard — loading states announced, data updates announced
- Tab through every interactive element on each page
- Verify focus indicator is always visible
- Verify no keyboard traps (can always Tab away)
- Verify modals trap focus correctly and restore focus on close
- Verify dropdowns open/close with Enter/Space/Escape
- Verify data tables are navigable with arrow keys
Test all pages in Windows High Contrast Mode and macOS Increase Contrast mode:
- All text remains readable
- Focus indicators remain visible
- Icons are not the sole means of conveying information
- Test at 200% browser zoom — no content clipped or overlapping
- Test with browser font size set to 200% — layout remains usable
| Issue | Component | WCAG Criterion | Status |
|---|---|---|---|
Profile page tab list missing role="tablist" wrapper |
ProfilePage |
4.1.2 | Open — tabs use role="tab" but no tablist container |
| Transaction table missing column headers | TransactionTables |
1.3.1 | Open — <th> elements needed |
| Color-only status indicators in GroupCard | GroupCard |
1.4.1 | Open — status badges rely on color alone |
Missing lang attribute on <html> |
index.html |
3.1.1 | Open — add lang="en" |
When adding a new component or page:
- Add an axe scan to
frontend/src/test/a11y.test.tsx:
describe('MyComponent – accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<MyComponent />);
expect(await axe(container)).toHaveNoViolations();
});
});-
Add keyboard navigation tests for any interactive elements.
-
Add the page URL to
frontend/.pa11yrc.jsonif it's a new route. -
Run
npm run test:a11ylocally before opening a PR.