Skip to content

Commit 3617ffc

Browse files
authored
Merge pull request #176 from Debbys-design/feat/accessibility-audit
feat(a11y): remediate axe violations, add live regions, keyboard nav,…
2 parents a4e1f66 + dc7cca4 commit 3617ffc

11 files changed

Lines changed: 361 additions & 64 deletions

File tree

.github/workflows/ci.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,69 @@ jobs:
167167
with:
168168
name: playwright-failure-${{ github.run_id }}
169169
path: |
170+
feat/accessibility-audit
171+
sbom-backend.json
172+
sbom-frontend.json
173+
retention-days: 90
174+
175+
# ── Frontend ──────────────────────────────────────────────────────────────
176+
frontend:
177+
name: Frontend (Next.js / TypeScript)
178+
runs-on: ubuntu-latest
179+
defaults:
180+
run:
181+
working-directory: frontend
182+
steps:
183+
- uses: actions/checkout@v4
184+
185+
- uses: actions/setup-node@v4
186+
with:
187+
node-version: 22
188+
cache: npm
189+
cache-dependency-path: frontend/package-lock.json
190+
191+
- run: npm ci
192+
- run: npm run lint
193+
- run: npm run check-docs
194+
- run: npm run build
195+
- run: npm test
196+
197+
# ── Accessibility (axe) ───────────────────────────────────────────────────
198+
accessibility:
199+
name: Accessibility (axe / Playwright)
200+
runs-on: ubuntu-latest
201+
defaults:
202+
run:
203+
working-directory: frontend
204+
steps:
205+
- uses: actions/checkout@v4
206+
207+
- uses: actions/setup-node@v4
208+
with:
209+
node-version: 22
210+
cache: npm
211+
cache-dependency-path: frontend/package-lock.json
212+
213+
- run: npm ci
214+
- run: npm run build
215+
216+
- name: Install Playwright browsers
217+
run: npx playwright install --with-deps chromium
218+
219+
- name: Run axe accessibility checks
220+
run: npx playwright test tests/accessibility.spec.ts --reporter=list
221+
env:
222+
BASE_URL: http://localhost:3000
223+
224+
- uses: actions/upload-artifact@v4
225+
if: failure()
226+
with:
227+
name: axe-report-${{ github.sha }}
228+
path: frontend/playwright-report/
229+
retention-days: 14
230+
170231
test-results
171232
playwright-report
172233
traces
173234
.playwright/traces
235+

CONTRIBUTING.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,86 @@
1+
feat/accessibility-audit
2+
# Contributing to NiffyInsur
3+
4+
## Accessibility Testing
5+
6+
Accessibility is a first-class requirement. Every PR that touches UI must pass the checks below before merge.
7+
8+
### Automated axe checks (CI)
9+
10+
The `accessibility` CI job runs `@axe-core/playwright` against the quote, policy, claims, and vote routes. **No critical violations are permitted.** The job uploads a Playwright report as an artifact on failure.
11+
12+
Run locally:
13+
14+
```bash
15+
cd frontend
16+
npm install
17+
npm run build
18+
npx playwright test tests/accessibility.spec.ts
19+
```
20+
21+
### Manual axe spot-check
22+
23+
1. Install the [axe DevTools browser extension](https://www.deque.com/axe/devtools/).
24+
2. Open each targeted route: `/quote`, `/policy`, `/claims`, `/claims/<id>`.
25+
3. Run the full-page scan. Resolve any **critical** or **serious** violations before opening a PR.
26+
27+
### Keyboard-only walkthrough
28+
29+
Verify these flows using only the keyboard (no mouse):
30+
31+
| Flow | Steps |
32+
|------|-------|
33+
| Get a quote | Tab through all form fields → submit → confirm quote preview updates |
34+
| Purchase policy | Complete all 4 wizard steps using Tab / Shift+Tab / Enter / Space |
35+
| File a claim | Complete all 4 wizard steps; confirm focus moves to new step heading on advance |
36+
| Cast a vote | Tab to Approve / Reject buttons → Enter to open confirm modal → Tab within modal → confirm or cancel |
37+
| Connect wallet | Tab to "Connect Wallet" button → Enter → confirm status announced |
38+
39+
Focus must always be visible. After a modal opens, focus must move inside it. After a modal closes, focus must return to the trigger.
40+
41+
### Screen reader spot-check (per major release)
42+
43+
Test at minimum one major flow per release with a screen reader:
44+
45+
- **macOS / iOS**: VoiceOver (`Cmd+F5` to toggle)
46+
- **Windows**: NVDA (free) or Narrator
47+
- **Android**: TalkBack
48+
49+
Checklist:
50+
- [ ] Transaction status updates are announced (aria-live regions on wizard and policy pages)
51+
- [ ] Step changes in wizards are announced (focus moves to hidden `<h2>` with step name)
52+
- [ ] Quote preview updates are announced on the quote page
53+
- [ ] Vote tally countdown is announced via `aria-live="polite"`
54+
- [ ] Modal title is read when dialog opens
55+
- [ ] Icon-only buttons have accessible names (aria-label or sr-only text)
56+
- [ ] Claim status badges convey outcome via text/shape, not color alone
57+
58+
### Reduced-motion
59+
60+
Verify that setting `prefers-reduced-motion: reduce` (OS accessibility setting or DevTools emulation) stops all non-essential animations. Loading spinners should become static; slide/fade transitions should be instant.
61+
62+
### Heading hierarchy
63+
64+
Each page must have exactly one `<h1>`. Use the browser Accessibility Tree panel (DevTools → Accessibility) or the [HeadingsMap extension](https://rumoroso.bitbucket.io/headingsmap/) to verify a logical heading order with no skipped levels.
65+
66+
### Landmarks
67+
68+
Every page must have at minimum: `<main>`, `<nav>` (if navigation present), and `<footer>` (if present). Verify with the Accessibility Tree or axe.
69+
70+
### Color contrast
71+
72+
All text must meet WCAG AA contrast ratios (4.5:1 normal text, 3:1 large text). Use the axe scan or the browser color-contrast checker. Claim outcomes (Approved / Rejected / Pending) must not rely on color alone — shape indicators and text labels are required.
73+
74+
### Adding new UI
75+
76+
When adding new interactive components:
77+
78+
1. Icon-only controls **must** have `aria-label` or a visually hidden label.
79+
2. Async state changes (transactions, loading) **must** update an `aria-live` region.
80+
3. Multi-step wizards **must** move focus to a step heading on step change.
81+
4. Modals **must** trap focus and return it to the trigger on close (Radix Dialog handles this automatically).
82+
5. Animations **must** respect `prefers-reduced-motion` via the global CSS rule in `globals.css`.
83+
184
# Contributing
285

386
## Soroban ABI golden vectors
@@ -48,3 +131,4 @@ Before tagging a release:
48131
- **Never** commit real private keys (Stellar secret keys start with `S`).
49132
- Use only placeholder G-addresses and C-addresses in vector `inputs`.
50133
- The CI job checks for secret-key patterns and will fail if any are found.
134+
main

frontend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"postcss": "^8.5.8",
4646
"tailwindcss": "^4.2.2",
4747
"ts-jest": "^29.4.6",
48-
"typescript": "^5"
48+
"typescript": "^5",
49+
"@axe-core/playwright": "^4.10.1",
50+
"@playwright/test": "^1.49.0"
4951
}
5052
}

frontend/playwright.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests',
5+
timeout: 30_000,
6+
use: {
7+
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
8+
headless: true,
9+
},
10+
webServer: process.env.BASE_URL
11+
? undefined
12+
: {
13+
command: 'npm run build && npx next start',
14+
url: 'http://localhost:3000',
15+
reuseExistingServer: !process.env.CI,
16+
timeout: 120_000,
17+
},
18+
});

frontend/src/app/globals.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,10 @@
121121
transition-duration: 0.01ms !important;
122122
scroll-behavior: auto !important;
123123
}
124+
125+
/* Tailwind animate-spin is used on loading spinners — keep it visible but
126+
instant so the element is still present for screen readers without motion */
127+
.animate-spin {
128+
animation: none !important;
129+
}
124130
}

frontend/src/components/claims/ClaimWizard.tsx

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
'use client';
22

3-
import React, { useState } from 'react';
3+
import React, { useState, useRef, useEffect } from 'react';
44
import { useRouter } from 'next/navigation';
55
import { Stepper, StepContent, Card, CardHeader, CardTitle, CardDescription, CardContent, Button, useToast } from '@/components/ui';
66
import { AmountStep } from './steps/AmountStep';
77
import { NarrativeStep } from './steps/NarrativeStep';
88
import { EvidenceStep } from './steps/EvidenceStep';
99
import { ReviewStep } from './steps/ReviewStep';
1010
import { ClaimAPI } from '@/lib/api/claim';
11-
import { useWallet } from '@/hooks/use-wallet'; // Assuming this exists based on common patterns
11+
import { useWallet } from '@/hooks/use-wallet';
1212
import { Loader2, ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react';
1313

1414
interface ClaimWizardProps {
@@ -30,13 +30,22 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
3030
const [activeStep, setActiveStep] = useState(0);
3131
const [isSubmitting, setIsSubmitting] = useState(false);
3232
const [isSuccess, setIsSuccess] = useState(false);
33+
const [txStatus, setTxStatus] = useState<string>('');
34+
const stepHeadingRef = useRef<HTMLHeadingElement>(null);
3335

3436
const [formData, setFormData] = useState({
3537
amount: '',
3638
details: '',
3739
imageUrls: [] as string[],
3840
});
3941

42+
// Move focus to step heading when step changes
43+
useEffect(() => {
44+
if (stepHeadingRef.current) {
45+
stepHeadingRef.current.focus();
46+
}
47+
}, [activeStep]);
48+
4049
const handleNext = () => {
4150
if (activeStep < STEPS.length - 1) {
4251
setActiveStep(prev => prev + 1);
@@ -65,7 +74,7 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
6574

6675
setIsSubmitting(true);
6776
try {
68-
// 1. Build unsigned transaction on backend
77+
setTxStatus('Building transaction…');
6978
const { unsignedXdr } = await ClaimAPI.buildTransaction({
7079
holder: address,
7180
policyId: parseInt(policyId),
@@ -74,28 +83,29 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
7483
imageUrls: formData.imageUrls,
7584
});
7685

77-
// 2. Sign with wallet
86+
setTxStatus('Waiting for wallet signature…');
7887
const signedXdr = await signTransaction(unsignedXdr);
7988

80-
// 3. Submit signed transaction
89+
setTxStatus('Submitting transaction to network…');
8190
await ClaimAPI.submitTransaction(signedXdr);
8291

83-
// 4. Success handling
92+
setTxStatus('Claim submitted successfully.');
8493
setIsSuccess(true);
8594
toast({
8695
title: 'Claim Submitted!',
8796
description: 'Your claim has been successfully filed on-chain.',
8897
});
8998

90-
// Redirect after a delay
9199
setTimeout(() => {
92100
router.push(`/policy/${policyId}`);
93101
}, 3000);
94102
} catch (error) {
103+
const msg = error instanceof Error ? error.message : 'An unexpected error occurred.';
104+
setTxStatus(`Submission failed: ${msg}`);
95105
console.error('Submission failed:', error);
96106
toast({
97107
title: 'Submission Failed',
98-
description: error instanceof Error ? error.message : 'An unexpected error occurred.',
108+
description: msg,
99109
variant: 'destructive',
100110
});
101111
} finally {
@@ -107,7 +117,10 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
107117
return (
108118
<Card className="mx-auto max-w-2xl text-center py-12">
109119
<CardContent className="space-y-6">
110-
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30">
120+
<div
121+
className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30"
122+
aria-hidden="true"
123+
>
111124
<CheckCircle className="h-12 w-12" />
112125
</div>
113126
<div className="space-y-2">
@@ -126,6 +139,11 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
126139

127140
return (
128141
<Card className="mx-auto max-w-3xl">
142+
{/* Live region announces tx progress to screen readers */}
143+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
144+
{txStatus}
145+
</div>
146+
129147
<CardHeader>
130148
<div className="flex items-center justify-between">
131149
<div>
@@ -137,11 +155,21 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
137155
<Stepper
138156
steps={STEPS.map((s, i) => ({ ...s, status: i < activeStep ? 'completed' : i === activeStep ? 'active' : 'pending' as const }))}
139157
currentStep={activeStep}
158+
aria-label="Claim filing steps"
140159
className="hidden md:flex"
141160
/>
142161
</div>
143162
</CardHeader>
144163
<CardContent className="space-y-6">
164+
{/* Visually hidden heading receives focus on step change */}
165+
<h2
166+
ref={stepHeadingRef}
167+
tabIndex={-1}
168+
className="sr-only focus:not-sr-only focus:outline-none"
169+
>
170+
Step {activeStep + 1} of {STEPS.length}: {STEPS[activeStep].title}
171+
</h2>
172+
145173
<StepContent title={STEPS[0].title} isActive={activeStep === 0} isCompleted={activeStep > 0}>
146174
<AmountStep
147175
amount={formData.amount}
@@ -169,32 +197,33 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
169197
</StepContent>
170198

171199
<div className="flex justify-between pt-4 border-t">
172-
<Button
173-
variant="ghost"
200+
<Button
201+
variant="ghost"
174202
onClick={handleBack}
175203
disabled={isSubmitting}
176204
>
177-
<ArrowLeft className="mr-2 h-4 w-4" />
205+
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
178206
{activeStep === 0 ? 'Cancel' : 'Back'}
179207
</Button>
180-
<Button
208+
<Button
181209
onClick={handleNext}
182210
disabled={
183-
isSubmitting ||
211+
isSubmitting ||
184212
(activeStep === 0 && !formData.amount) ||
185213
(activeStep === 1 && !formData.details) ||
186214
(activeStep === 3 && isSubmitting)
187215
}
216+
aria-busy={isSubmitting}
188217
>
189218
{isSubmitting ? (
190219
<>
191-
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
192-
Processing...
220+
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
221+
Processing
193222
</>
194223
) : (
195224
<>
196225
{activeStep === STEPS.length - 1 ? 'Sign & Submit' : 'Next'}
197-
<ArrowRight className="ml-2 h-4 w-4" />
226+
<ArrowRight className="ml-2 h-4 w-4" aria-hidden="true" />
198227
</>
199228
)}
200229
</Button>

0 commit comments

Comments
 (0)