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 8921bb34..6660358f 100644 --- a/app/frontend/src/app/[locale]/campaigns/page.tsx +++ b/app/frontend/src/app/[locale]/campaigns/page.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { useMemo, useState, useCallback } from 'react'; +import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { AppEmptyState } from '@/components/empty-state/AppEmptyState'; import { ExportControls } from '@/components/dashboard/ExportControls'; @@ -53,12 +53,15 @@ 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); - const previousCampaignsRef = React.useRef(campaigns); + const previousCampaignsRef = useRef(campaigns); const [announcement, setAnnouncement] = useState(''); - React.useEffect(() => { + useEffect(() => { if (campaigns.length > 0 && previousCampaignsRef.current.length > 0) { for (const campaign of campaigns) { const prev = previousCampaignsRef.current.find(c => c.id === campaign.id); @@ -103,12 +106,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

@@ -116,14 +122,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; } @@ -143,9 +149,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', + }); } }; @@ -178,8 +187,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 96324a63..d007395c 100644 --- a/app/frontend/src/app/[locale]/claim-receipt/page.tsx +++ b/app/frontend/src/app/[locale]/claim-receipt/page.tsx @@ -98,7 +98,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 @@ -110,8 +110,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/app/frontend/test/live-region.spec.tsx b/app/frontend/test/live-region.spec.tsx index e52393ad..27623700 100644 --- a/app/frontend/test/live-region.spec.tsx +++ b/app/frontend/test/live-region.spec.tsx @@ -1,3 +1,4 @@ +/** @jest-environment jsdom */ import React from 'react'; import { render, act } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/docs/accessibility/audit-2026.md b/docs/accessibility/audit-2026.md new file mode 100644 index 00000000..3bc78f3c --- /dev/null +++ b/docs/accessibility/audit-2026.md @@ -0,0 +1,112 @@ +# 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/fr-CA/pt-BR/sw 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 `