From ba97d4e2fed730ba88808d0c972f3f9389ef95c8 Mon Sep 17 00:00:00 2001 From: Mapelujo Abdulkareem Date: Fri, 17 Jul 2026 22:30:37 +0100 Subject: [PATCH 1/6] fix: remove trailing comma making root package.json invalid JSON --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index df23dfa0..b5a1524d 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "pnpm -r build", "lint": "pnpm -r lint", - "test": "pnpm -r test", - }, + "test": "pnpm -r test" + }, "pnpm": { "overrides": { "jest": "^30.4.2", From db05c3f2767c81a4ccef6ef51148d66c399e275e Mon Sep 17 00:00:00 2001 From: Mapelujo Abdulkareem Date: Fri, 17 Jul 2026 22:30:37 +0100 Subject: [PATCH 2/6] feat(frontend): add WCAG 2.2 AA a11y audit, axe CI suite and fixes (#293) --- .github/workflows/frontend-ci.yml | 46 ++++ app/frontend/package.json | 6 +- .../src/app/[locale]/campaigns/page.tsx | 30 ++- .../src/app/[locale]/claim-receipt/page.tsx | 24 +- app/frontend/src/app/[locale]/page.tsx | 10 +- .../src/components/AidPackageList.tsx | 4 +- app/frontend/test/accessibility.spec.tsx | 208 ++++++++++++++++++ docs/accessibility/audit-2026.md | 111 ++++++++++ pnpm-lock.yaml | 94 +++++--- 9 files changed, 475 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 app/frontend/test/accessibility.spec.tsx create mode 100644 docs/accessibility/audit-2026.md diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 00000000..2f4f7322 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,46 @@ +name: Frontend CI + +on: + push: + branches: [ "main" ] + paths: + - 'app/frontend/**' + - '.github/workflows/frontend-ci.yml' + pull_request: + branches: [ "main" ] + paths: + - 'app/frontend/**' + - '.github/workflows/frontend-ci.yml' + +jobs: + lint-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile --engine-strict=false + + # Non-blocking until the pre-existing lint errors on main are fixed + # (tracked in docs/accessibility/audit-2026.md ticket backlog). + - name: Lint + continue-on-error: true + run: pnpm --filter chainforge-frontend run lint + + - name: Type check + run: pnpm --filter chainforge-frontend run type-check + + - name: Test (includes axe accessibility suite) + run: pnpm --filter chainforge-frontend run test diff --git a/app/frontend/package.json b/app/frontend/package.json index 37b34b2e..00c71291 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@heroicons/react": "^2.2.0", - "openapi-fetch": "^0.13.5", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -29,6 +28,7 @@ "next": "^16.2.1", "next-intl": "^4.9.1", "next-themes": "^0.4.6", + "openapi-fetch": "^0.13.5", "papaparse": "^5.5.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -38,10 +38,10 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "openapi-typescript": "^7.6.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^30.0.0", + "@types/jest-axe": "^3.5", "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/papaparse": "^5.3.16", @@ -50,7 +50,9 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.1", "jest": "^30.4.2", + "jest-axe": "^10", "jest-environment-jsdom": "^30.0.0", + "openapi-typescript": "^7.6.1", "tailwindcss": "^4", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", diff --git a/app/frontend/src/app/[locale]/campaigns/page.tsx b/app/frontend/src/app/[locale]/campaigns/page.tsx index 4e8ec5be..50e56af3 100644 --- a/app/frontend/src/app/[locale]/campaigns/page.tsx +++ b/app/frontend/src/app/[locale]/campaigns/page.tsx @@ -51,7 +51,10 @@ export default function CampaignsPage() { const [budget, setBudget] = useState(''); const [token, setToken] = useState('USDC'); const [expiry, setExpiry] = useState(''); - const [formMessage, setFormMessage] = useState(null); + const [formMessage, setFormMessage] = useState<{ + text: string; + kind: 'success' | 'error'; + } | null>(null); function updateParam(key: string, value: string) { const params = new URLSearchParams(searchParams.toString()); @@ -86,12 +89,15 @@ export default function CampaignsPage() { setBudget('15000'); setToken('USDC'); setExpiry('2026-12-31'); - setFormMessage('Sample campaign values loaded. Review and create when ready.'); + setFormMessage({ + text: 'Sample campaign values loaded. Review and create when ready.', + kind: 'success', + }); }; if (!canManageCampaigns(userRole)) { return ( -
+

Access Denied

@@ -99,14 +105,14 @@ export default function CampaignsPage() { {userRoleLabel}.

-
+ ); } const handleCreate = async (event: React.FormEvent) => { event.preventDefault(); if (!name.trim() || !budget.trim()) { - setFormMessage('Name and budget are required.'); + setFormMessage({ text: 'Name and budget are required.', kind: 'error' }); return; } @@ -126,9 +132,12 @@ export default function CampaignsPage() { setBudget(''); setToken('USDC'); setExpiry(''); - setFormMessage('Campaign created successfully.'); + setFormMessage({ text: 'Campaign created successfully.', kind: 'success' }); } catch (err) { - setFormMessage((err as Error).message ?? 'Failed to create campaign.'); + setFormMessage({ + text: (err as Error).message ?? 'Failed to create campaign.', + kind: 'error', + }); } }; @@ -160,8 +169,11 @@ export default function CampaignsPage() {

Create New Campaign

{formMessage && ( -
- {formMessage} +
+ {formMessage.text}
)}
diff --git a/app/frontend/src/app/[locale]/claim-receipt/page.tsx b/app/frontend/src/app/[locale]/claim-receipt/page.tsx index b7f054be..22ca03c4 100644 --- a/app/frontend/src/app/[locale]/claim-receipt/page.tsx +++ b/app/frontend/src/app/[locale]/claim-receipt/page.tsx @@ -85,7 +85,7 @@ export default function ClaimReceiptPage() { onClick={() => router.back()} className="text-blue-600 dark:text-blue-400 hover:underline mb-4 flex items-center gap-2" > - ← Back + Back

Claim Receipt @@ -97,8 +97,15 @@ export default function ClaimReceiptPage() { {/* Loading State */} {loading && ( -
- +
+

On-chain anchoring of distributions and impact reports.

diff --git a/app/frontend/src/components/AidPackageList.tsx b/app/frontend/src/components/AidPackageList.tsx index 324a9dd2..dab7a583 100644 --- a/app/frontend/src/components/AidPackageList.tsx +++ b/app/frontend/src/components/AidPackageList.tsx @@ -15,7 +15,7 @@ function PackageCard({ pkg }: { pkg: AidPackage }) {
-

{pkg.title}

+

{pkg.title}

ID: {pkg.id}

{pkg.region}

@@ -53,7 +53,7 @@ export const AidPackageList: React.FC = () => { return (
-

Available Aid Packages

+

Available Aid Packages

{packages.map(pkg => ( diff --git a/app/frontend/test/accessibility.spec.tsx b/app/frontend/test/accessibility.spec.tsx new file mode 100644 index 00000000..520db1bd --- /dev/null +++ b/app/frontend/test/accessibility.spec.tsx @@ -0,0 +1,208 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { AidPackage } from '@/types/aid-package'; +import type { Campaign } from '@/types/campaign'; +import Home from '@/app/[locale]/page'; +import CampaignsPage from '@/app/[locale]/campaigns/page'; +import ClaimReceiptPage from '@/app/[locale]/claim-receipt/page'; + +expect.extend(toHaveNoViolations); + +// jsdom has no layout engine, so color-contrast cannot be computed here. +// Contrast is tracked in docs/accessibility/audit-2026.md as a manual check. +const axeConfig = { + rules: { + 'color-contrast': { enabled: false }, + }, +}; + +const mockSearchParams = { current: new URLSearchParams() }; + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + back: jest.fn(), + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }), + useSearchParams: () => mockSearchParams.current, + usePathname: () => '/', +})); + +const aidPackages: AidPackage[] = [ + { + id: 'pkg-001', + title: 'Winter Relief Kit', + region: 'Northern Region', + amount: '150', + recipients: 40, + status: 'Active', + token: 'USDC', + }, + { + id: 'pkg-002', + title: 'Food Assistance', + region: 'Coastal Region', + amount: '75', + recipients: 120, + status: 'Claimed', + token: 'XLM', + }, +]; + +jest.mock('@/hooks/useAidPackages', () => ({ + useAidPackages: () => ({ + data: aidPackages, + isLoading: false, + error: null, + }), +})); + +const campaigns: Campaign[] = [ + { + id: 'camp-001', + name: 'Winter Relief 2026', + budget: 25000, + status: 'active', + metadata: { token: 'USDC', expiry: '2026-12-31T00:00:00.000Z' }, + }, + { + id: 'camp-002', + name: 'Emergency Cash Transfer', + budget: 15000, + status: 'paused', + metadata: { token: 'XLM' }, + }, +]; + +jest.mock('@/hooks/useCampaigns', () => ({ + useCampaigns: () => ({ + data: campaigns, + isLoading: false, + isError: false, + error: null, + }), + useCreateCampaign: () => ({ + mutateAsync: jest.fn(), + isPending: false, + }), +})); + +jest.mock('@/hooks/useOptimisticCampaignMutations', () => ({ + useCampaignAction: () => ({ + mutate: jest.fn(), + isPending: false, + variables: undefined, + }), + useCampaignActions: () => ({ + canPause: true, + canResume: false, + canArchive: true, + canComplete: true, + canActivate: false, + }), +})); + +beforeAll(() => { + // next-themes reads matchMedia, which jsdom does not implement + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}); + +const savedUserRole = process.env.NEXT_PUBLIC_USER_ROLE; + +beforeEach(() => { + mockSearchParams.current = new URLSearchParams(); + delete process.env.NEXT_PUBLIC_USER_ROLE; +}); + +afterEach(() => { + if (savedUserRole === undefined) { + delete process.env.NEXT_PUBLIC_USER_ROLE; + } else { + process.env.NEXT_PUBLIC_USER_ROLE = savedUserRole; + } +}); + +// The pages under test render hardcoded English copy and do not call +// next-intl hooks, so no NextIntlClientProvider is needed (next-intl is +// ESM-only and would require extra jest transform config). +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +describe('accessibility (axe, WCAG 2.2 AA automated subset)', () => { + it('home page has no axe violations', async () => { + const ui = await Home({ params: Promise.resolve({ locale: 'en' }) }); + const { container } = renderWithProviders(ui); + + await waitFor(() => { + expect(screen.getByText('Available Aid Packages')).toBeInTheDocument(); + }); + + expect(await axe(container, axeConfig)).toHaveNoViolations(); + }); + + it('campaigns page (ngo role) has no axe violations', async () => { + process.env.NEXT_PUBLIC_USER_ROLE = 'ngo'; + const { container } = renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('NGO Campaigns')).toBeInTheDocument(); + expect(screen.getByText('Winter Relief 2026')).toBeInTheDocument(); + }); + + expect(await axe(container, axeConfig)).toHaveNoViolations(); + }); + + it('campaigns page (guest role, access denied) has no axe violations', async () => { + const { container } = renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Access Denied')).toBeInTheDocument(); + }); + + expect(await axe(container, axeConfig)).toHaveNoViolations(); + }); + + it('claim receipt page with claimId has no axe violations', async () => { + mockSearchParams.current = new URLSearchParams('claimId=claim-123'); + const { container } = renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('What is this receipt?')).toBeInTheDocument(); + }); + + expect(await axe(container, axeConfig)).toHaveNoViolations(); + }); + + it('claim receipt page without claimId (error state) has no axe violations', async () => { + const { container } = renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Claim ID not provided')).toBeInTheDocument(); + }); + + expect(await axe(container, axeConfig)).toHaveNoViolations(); + }); +}); diff --git a/docs/accessibility/audit-2026.md b/docs/accessibility/audit-2026.md new file mode 100644 index 00000000..f9703a4d --- /dev/null +++ b/docs/accessibility/audit-2026.md @@ -0,0 +1,111 @@ +# Frontend Accessibility Audit — WCAG 2.2 AA (2026) + +- **Date:** 2026-07-17 +- **Issue:** #293 — Accessibility audit: WCAG 2.2 AA cross-cutting fixes +- **Target:** WCAG 2.2 Level AA +- **Scope:** `app/frontend` pages `/` (home), `/campaigns`, `/claim-receipt`, plus cross-cutting concerns (root layout, Navbar) +- **Methodology:** automated axe-core scan via jest-axe in jsdom (`app/frontend/test/accessibility.spec.tsx`, enforced in CI by `.github/workflows/frontend-ci.yml`) plus manual code review of the page sources. + +## Limits of the automated scan + +jest-axe runs in jsdom, which has **no layout engine**. The following cannot be +verified automatically here and need a browser-based pass (see ticket backlog): + +- **Color contrast (1.4.3)** — the `color-contrast` rule is explicitly disabled in the jest suite. +- **Focus visibility (2.4.7 / 2.4.11)** — requires rendered focus styles. +- **Reflow / zoom (1.4.10)** — requires real viewport rendering. +- **Target size (2.5.8, new in WCAG 2.2)** — requires computed element geometry. +- Real assistive-technology behavior (screen reader announcement order, etc.). + +## Scope notes + +- The issue names `/claims/[id]`, but **that route does not exist** in the + frontend. The closest page is `/[locale]/claim-receipt?claimId=…` (client + component, currently rendering mock data). This audit covers `/claim-receipt` + in its place (maintainer-confirmed). Creating a real `/claims/[id]` route with + API wiring is listed as a ticket below. +- Locale routing (`/[locale]/`, en/es/fr via next-intl) exists, but the audited + pages render **hardcoded English copy** and do not call translation hooks. + Related: the root layout hardcodes `` for every locale. + +## Per-page results + +Legend: **Pass** · **Fail** · **Fixed** (in this PR) · **Manual** (needs browser/AT check) + +### `/` (home) — `src/app/[locale]/page.tsx`, `src/components/AidPackageList.tsx` + +| Criterion | Result | Notes | +|---|---|---| +| 1.3.1 Info and Relationships (heading order) | **Fixed** | h1 → h3 skips: feature cards and "Available Aid Packages" were `

` directly under the page `

`; package card titles were `

`. Changed to `

`/`

` (Tailwind classes unchanged, no visual shift). | +| 1.1.1 Non-text Content | Pass | No informative images; status badges are text. | +| 2.4.4 Link Purpose | Pass | "Get Started" link text is descriptive. | +| 2.1.1 Keyboard | Fail | "Learn More" button is focusable but has no handler — it does nothing for any user (functional bug with a11y impact; ticket). | +| 1.4.3 Contrast | Manual | Not verifiable in jsdom. | +| 4.1.2 Name, Role, Value | Pass | Native elements throughout. | + +### `/campaigns` — `src/app/[locale]/campaigns/page.tsx` + +| Criterion | Result | Notes | +|---|---|---| +| 1.3.1 Landmarks (`region` best practice) | **Fixed** | "Access Denied" branch rendered outside any landmark; now wrapped in `
`. The NGO branch already had `
`. | +| 4.1.3 Status Messages | **Fixed** | Form feedback (`formMessage`) now renders `role="status"` for success and `role="alert"` for errors, so screen readers announce it without focus moves. | +| 1.3.1 / 3.3.2 Labels | Pass | All four form inputs are wrapped in `