client_v2: add mobile accessibility audit (Playwright + axe-core)

Adds an npm run audit:a11y target that runs 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 under Pixel 5 emulation, reusing the dev server on 5174.
Results are aggregated to a11y-report.md via globalTeardown so they survive
Playwright worker restarts.
This commit is contained in:
Donkie committed 2026-07-25 19:07:24 +02:00
1 parent b7772ca743
commit 8a5cf7ec73
10 files changed
+465 -2

No files matched your search

+5
View File
@@ -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
+14
View File
@@ -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`.
+190
View File
@@ -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<Element>();
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 <a> 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');
});
+10
View File
@@ -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 });
}
+80
View File
@@ -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)`);
}
+35
View File
@@ -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[];
}
+3 -1
View File
@@ -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,
+88
View File
@@ -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",
+5 -1
View File
@@ -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",
+35
View File
@@ -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
}
});