diff --git a/client_v2/.gitignore b/client_v2/.gitignore index 040463bb..af1468b9 100644 --- a/client_v2/.gitignore +++ b/client_v2/.gitignore @@ -7,3 +7,8 @@ node_modules !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Playwright a11y audit output +/playwright-report +/test-results +/a11y-report.md diff --git a/client_v2/README.md b/client_v2/README.md index 74b59486..07d8a592 100644 --- a/client_v2/README.md +++ b/client_v2/README.md @@ -158,3 +158,17 @@ npm run dev # http://localhost:5174 npm run check # svelte-check (type gate) npm run build # static SPA build (adapter-static, index.html fallback) ``` + +### Mobile accessibility audit + +```bash +npm run audit:a11y # Playwright + axe-core under Pixel 5 emulation +npm run audit:a11y:report # open the last HTML report +``` + +Runs [axe-core](https://github.com/dequelabs/axe-core) (WCAG 2.0/2.1 A+AA plus the +2.2 `target-size` rule) and a custom tap-target measurement across the main +surfaces (library, locations, labels, settings, add-spool modal, inspector +drawer). Reuses a `vite dev` already on 5174, or starts one. Human-readable +results land in `a11y-report.md`; the tap-target advisory minimum is 44px (Apple +HIG / Material), stricter than the WCAG 24px floor. See `e2e/a11y-mobile.spec.ts`. diff --git a/client_v2/e2e/a11y-mobile.spec.ts b/client_v2/e2e/a11y-mobile.spec.ts new file mode 100644 index 00000000..76ced8e7 --- /dev/null +++ b/client_v2/e2e/a11y-mobile.spec.ts @@ -0,0 +1,190 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { SURFACES_DIR, TAP_MIN, type SurfaceResult, type TapTarget } from './report'; + +// Mobile-accessibility audit for client_v2. +// +// Two independent checks run against every surface, under a Pixel 5 mobile +// emulation (see playwright.config.ts): +// +// 1. axe-core — the full WCAG 2.0/2.1 A + AA ruleset PLUS the WCAG 2.2 +// `target-size` rule (SC 2.5.8, 24x24 CSS px minimum). Real conformance +// failures. +// +// 2. A custom tap-target measurement — flags interactive controls smaller +// than TAP_MIN px in either dimension. An *advisory* thumb-comfort check +// (Apple HIG 44px / Material 48px), stricter than the WCAG minimum and +// catching targets axe considers technically compliant. +// +// Each surface's findings are written to SURFACES_DIR as JSON; the consolidated +// `a11y-report.md` is rendered by globalTeardown (which survives worker +// restarts). Raw findings are also attached to the Playwright HTML report. + +let surfaceCount = 0; + +// Let the DOM and any async lists settle. The app holds an open live-sync +// WebSocket, so `networkidle` never fires — we bound it and fall through. +async function settle(page: Page, ms = 500) { + await page.waitForLoadState('domcontentloaded').catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {}); + await page.waitForTimeout(ms); +} + +// Measure every interactive control in the live DOM and return those smaller +// than `min` px in either dimension. Runs in the browser. +function collectSmallTargets(min: number): TapTarget[] { + const SELECTOR = [ + 'a[href]', + 'button', + 'input:not([type="hidden"])', + 'select', + 'textarea', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="menuitem"]', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + + const out: TapTarget[] = []; + const seen = new Set(); + + for (const el of Array.from(document.querySelectorAll(SELECTOR))) { + if (seen.has(el)) continue; + seen.add(el); + + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue; + if ((el as HTMLButtonElement).disabled) continue; + + // WCAG exempts targets rendered inline within a run of text (e.g. a link + // inside a sentence). Skip inline elements to avoid false positives. + if (el.tagName === 'A' && style.display.startsWith('inline')) continue; + + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; // not rendered + if (rect.width >= min && rect.height >= min) continue; + + const id = el.id ? `#${el.id}` : ''; + const rawCls = (el as HTMLElement).className; + const cls = + typeof rawCls === 'string' && rawCls.trim() + ? '.' + rawCls.trim().split(/\s+/).slice(0, 2).join('.') + : ''; + const aria = el.getAttribute('aria-label'); + + out.push({ + tag: el.tagName.toLowerCase(), + text: (aria || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40), + selector: `${el.tagName.toLowerCase()}${id}${cls}`, + width: Math.round(rect.width), + height: Math.round(rect.height) + }); + } + return out; +} + +// Run both checks against whatever is currently rendered and record the result. +async function audit(page: Page, testInfo: TestInfo, label: string) { + await settle(page); + + const axeResults = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + + const smallTargets = await page.evaluate(collectSmallTargets, TAP_MIN); + + const surface: SurfaceResult = { + order: surfaceCount++, + label, + url: page.url(), + axe: axeResults.violations.map((v) => ({ + id: v.id, + impact: v.impact, + help: v.help, + nodes: v.nodes.length, + targets: v.nodes.slice(0, 5).map((n) => n.target.join(' ')) + })), + smallTargets + }; + + const file = path.join(SURFACES_DIR, `${label.replace(/[^a-z0-9]+/gi, '-')}.json`); + await writeFile(file, JSON.stringify(surface, null, 2), 'utf8'); + + await testInfo.attach(`${label} — axe violations`, { + body: JSON.stringify(axeResults.violations, null, 2), + contentType: 'application/json' + }); + await testInfo.attach(`${label} — small tap targets`, { + body: JSON.stringify(smallTargets, null, 2), + contentType: 'application/json' + }); + + // Soft assertions: the run completes and reports every surface, but the test + // is still marked failed if anything is out of spec — useful as a CI gate. + const serious = surface.axe.filter((v) => v.impact === 'serious' || v.impact === 'critical'); + expect.soft(serious, `${label}: serious/critical axe violations`).toEqual([]); + expect.soft(smallTargets, `${label}: tap targets under ${TAP_MIN}px`).toEqual([]); +} + +const STATIC_ROUTES: { path: string; label: string }[] = [ + { path: '/', label: 'library (home)' }, + { path: '/locations', label: 'locations' }, + { path: '/labels', label: 'labels' }, + { path: '/settings', label: 'settings' } +]; + +for (const route of STATIC_ROUTES) { + test(`a11y: ${route.label}`, async ({ page }, testInfo) => { + await page.goto(route.path); + await audit(page, testInfo, route.label); + }); +} + +test('a11y: add-spool modal', async ({ page }, testInfo) => { + await page.goto('/'); + await settle(page); + // The mobile TopBar exposes the add control via aria-label "Add spools". + const addBtn = page.getByRole('button', { name: /add spool/i }).first(); + if ((await addBtn.count()) === 0) test.skip(true, 'no add button found'); + await addBtn.click(); + await audit(page, testInfo, 'add-spool modal'); +}); + +test('a11y: inspector drawer (mobile bottom sheet)', async ({ page }, testInfo) => { + await page.goto('/'); + await settle(page, 800); + // Select the first library row to open the mobile drawer. + const firstItem = page.locator('.list-pane a[href], .list-pane [role="button"]').first(); + if ((await firstItem.count()) === 0) test.skip(true, 'no list items to select'); + await firstItem.click({ timeout: 5000 }).catch(() => {}); + await audit(page, testInfo, 'inspector drawer'); +}); + +test('a11y: detail pages (filament / spool)', async ({ page }, testInfo) => { + await page.goto('/'); + await settle(page, 800); + + // Discover a real detail URL from the live DOM rather than hardcoding an id. + const hrefs = await page.$$eval('a[href]', (els) => + els.map((e) => (e as HTMLAnchorElement).getAttribute('href') || '') + ); + const filament = hrefs.find((h) => /\/filament\/show\/\d/.test(h)); + const spool = hrefs.find((h) => /\/spool\/show\/\d/.test(h)); + + if (filament) { + await page.goto(filament); + await audit(page, testInfo, 'filament detail'); + } + if (spool) { + await page.goto(spool); + await audit(page, testInfo, 'spool detail'); + } + if (!filament && !spool) test.skip(true, 'no detail links found in library'); +}); diff --git a/client_v2/e2e/global-setup.ts b/client_v2/e2e/global-setup.ts new file mode 100644 index 00000000..a926f827 --- /dev/null +++ b/client_v2/e2e/global-setup.ts @@ -0,0 +1,10 @@ +import { rm, mkdir } from 'node:fs/promises'; +import { SURFACES_DIR, REPORT_PATH } from './report'; + +// Runs once before the suite: clear any per-surface results and the report from +// a previous run so the new report reflects only this run. +export default async function globalSetup() { + await rm(SURFACES_DIR, { recursive: true, force: true }); + await rm(REPORT_PATH, { force: true }); + await mkdir(SURFACES_DIR, { recursive: true }); +} diff --git a/client_v2/e2e/global-teardown.ts b/client_v2/e2e/global-teardown.ts new file mode 100644 index 00000000..1f662017 --- /dev/null +++ b/client_v2/e2e/global-teardown.ts @@ -0,0 +1,80 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { SURFACES_DIR, REPORT_PATH, TAP_MIN, type SurfaceResult } from './report'; + +// Runs once after the suite (in the main process, so it survives worker +// restarts): read every per-surface JSON and render the consolidated report. +export default async function globalTeardown() { + let files: string[] = []; + try { + files = (await readdir(SURFACES_DIR)).filter((f) => f.endsWith('.json')); + } catch { + return; + } + + const results: SurfaceResult[] = []; + for (const f of files) { + try { + results.push(JSON.parse(await readFile(path.join(SURFACES_DIR, f), 'utf8'))); + } catch { + /* ignore malformed */ + } + } + results.sort((a, b) => a.order - b.order); + + const esc = (s: string) => s.replace(/\|/g, '\\|'); + const lines: string[] = []; + lines.push('# Mobile accessibility audit — client_v2'); + lines.push(''); + lines.push(`Device profile: **Pixel 5** (393×851 CSS px, touch). Generated ${new Date().toISOString()}.`); + lines.push(''); + lines.push( + `Advisory tap-target minimum: **${TAP_MIN}px**. WCAG 2.2 target-size (24px) failures appear under axe.` + ); + lines.push(''); + + const totalAxe = results.reduce((n, s) => n + s.axe.reduce((m, v) => m + v.nodes, 0), 0); + const totalSmall = results.reduce((n, s) => n + s.smallTargets.length, 0); + lines.push('## Summary'); + lines.push(''); + lines.push(`- Surfaces audited: **${results.length}**`); + lines.push(`- Total axe violation instances: **${totalAxe}**`); + lines.push(`- Total tap targets under ${TAP_MIN}px: **${totalSmall}**`); + lines.push(''); + + for (const s of results) { + lines.push(`## ${s.label}`); + lines.push(`\`${s.url}\``); + lines.push(''); + if (s.axe.length === 0) { + lines.push('**axe:** no violations ✅'); + } else { + lines.push('**axe violations:**'); + lines.push(''); + lines.push('| Rule | Impact | Instances | Example target |'); + lines.push('| --- | --- | --- | --- |'); + for (const v of s.axe) { + lines.push( + `| ${v.id} — ${esc(v.help)} | ${v.impact ?? '?'} | ${v.nodes} | \`${esc((v.targets[0] ?? '').slice(0, 50))}\` |` + ); + } + } + lines.push(''); + if (s.smallTargets.length === 0) { + lines.push(`**Tap targets:** all ≥ ${TAP_MIN}px ✅`); + } else { + lines.push(`**Tap targets under ${TAP_MIN}px (${s.smallTargets.length}):**`); + lines.push(''); + lines.push('| Element | Size (w×h) | Text/label |'); + lines.push('| --- | --- | --- |'); + for (const t of s.smallTargets.slice(0, 30)) { + lines.push(`| \`${esc(t.selector.slice(0, 40))}\` | ${t.width}×${t.height} | ${esc(t.text)} |`); + } + if (s.smallTargets.length > 30) lines.push(`| … | | +${s.smallTargets.length - 30} more |`); + } + lines.push(''); + } + + await writeFile(REPORT_PATH, lines.join('\n'), 'utf8'); + console.log(`\nWrote ${path.relative(process.cwd(), REPORT_PATH)} (${results.length} surfaces)`); +} diff --git a/client_v2/e2e/report.ts b/client_v2/e2e/report.ts new file mode 100644 index 00000000..f2e6a5cc --- /dev/null +++ b/client_v2/e2e/report.ts @@ -0,0 +1,35 @@ +// Shared types and paths for the mobile-a11y audit's file-based result +// aggregation. Each test writes one JSON file per audited surface into +// SURFACES_DIR; globalTeardown reads them all and renders a11y-report.md. +// +// We go through disk rather than module-level state because Playwright may +// recycle the worker process between failing tests, which would wipe any +// in-memory accumulator before the final report is written. +import path from 'node:path'; + +export const TAP_MIN = 44; + +export const SURFACES_DIR = path.resolve('test-results', 'a11y-surfaces'); +export const REPORT_PATH = path.resolve('a11y-report.md'); + +export interface TapTarget { + tag: string; + text: string; + selector: string; + width: number; + height: number; +} + +export interface SurfaceResult { + order: number; + label: string; + url: string; + axe: { + id: string; + impact: string | null | undefined; + help: string; + nodes: number; + targets: string[]; + }[]; + smallTargets: TapTarget[]; +} diff --git a/client_v2/eslint.config.js b/client_v2/eslint.config.js index c9a9f170..2169aa05 100644 --- a/client_v2/eslint.config.js +++ b/client_v2/eslint.config.js @@ -15,7 +15,9 @@ export default ts.config( 'package/', 'src/lib/paraglide/', // auto-generated by inlang paraglide-js 'project.inlang/', // inlang-managed - 'locales/' // Weblate-managed translation files + 'locales/', // Weblate-managed translation files + 'playwright-report/', // generated a11y-audit report (bundled/minified JS) + 'test-results/' // generated Playwright artifacts ] }, js.configs.recommended, diff --git a/client_v2/package-lock.json b/client_v2/package-lock.json index 0abdf900..b0d31174 100644 --- a/client_v2/package-lock.json +++ b/client_v2/package-lock.json @@ -17,8 +17,10 @@ "svelte-konva": "^1.0.1" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.5", "@inlang/paraglide-js": "^2.18.2", + "@playwright/test": "^1.62.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.1", "@sveltejs/vite-plugin-svelte": "^4.0.0", @@ -36,6 +38,19 @@ "vite": "^5.4.10" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -784,6 +799,22 @@ "svelte": "^5" } }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1696,6 +1727,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2925,6 +2966,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/client_v2/package.json b/client_v2/package.json index ce76f85c..9f97f7c1 100644 --- a/client_v2/package.json +++ b/client_v2/package.json @@ -14,11 +14,15 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint .", - "lint:eslint": "eslint ." + "lint:eslint": "eslint .", + "audit:a11y": "playwright test", + "audit:a11y:report": "playwright show-report" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.5", "@inlang/paraglide-js": "^2.18.2", + "@playwright/test": "^1.62.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.1", "@sveltejs/vite-plugin-svelte": "^4.0.0", diff --git a/client_v2/playwright.config.ts b/client_v2/playwright.config.ts new file mode 100644 index 00000000..32f65795 --- /dev/null +++ b/client_v2/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Playwright config dedicated to the mobile-accessibility audit. It runs a +// single mobile-emulated project (Pixel 5: 393x851 CSS px, touch enabled) so +// tap-target geometry and mobile layout are measured under realistic phone +// conditions. +// +// The webServer block reuses an already-running `vite dev` on 5174 (the usual +// dev loop) and only spins one up if nothing is listening there. +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + baseURL: process.env.AUDIT_BASE_URL ?? 'http://localhost:5174', + trace: 'retain-on-failure' + }, + projects: [ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] } + } + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5174', + reuseExistingServer: true, + timeout: 120_000 + } +});