diff --git a/.gitignore b/.gitignore index 5204865..b8f9b57 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ build/ **/dev-dist/ **/coverage/ +# Playwright (a11y test runner) +**/test-results/ +**/playwright-report/ +**/.playwright/ + # vue-router auto-generated typed routes **/typed-router.d.ts diff --git a/apps/client/e2e/a11y.spec.ts b/apps/client/e2e/a11y.spec.ts new file mode 100644 index 0000000..86d56c4 --- /dev/null +++ b/apps/client/e2e/a11y.spec.ts @@ -0,0 +1,73 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +// WCAG 2.0 + 2.1, levels A and AA — the conformance target for RaidMate. +const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; + +/** + * Run axe over the current page state and assert zero violations. On failure + * the violations are attached to the test (id, impact, rule, offending nodes) + * so the report is actionable without re-running. + */ +async function expectNoA11yViolations(page: Page, label: string): Promise { + const { violations } = await new AxeBuilder({ page }).withTags(WCAG_AA_TAGS).analyze(); + if (violations.length > 0) { + const detail = violations + .map( + (v) => + `[${v.impact}] ${v.id} — ${v.help} (${v.nodes.length})\n` + + v.nodes + .slice(0, 8) + .map((n) => ` ${n.target.join(' ')}`) + .join('\n'), + ) + .join('\n'); + test.info().annotations.push({ type: `axe:${label}`, description: detail }); + } + expect(violations, `WCAG A/AA violations in: ${label}`).toEqual([]); +} + +test.beforeEach(async ({ page }) => { + // Force English so accessible names (which we select by below) are + // deterministic regardless of the runner's navigator.language. Matches + // persistedRef's storage format: JSON.stringify('en'). + await page.addInitScript(() => { + try { + localStorage.setItem('rm.i18n.apiLang', '"en"'); + } catch { + /* storage unavailable — fall back to default locale */ + } + }); + await page.goto('/'); + // App shell + Leaflet map mounted. + await page.locator('[role="application"]').waitFor(); +}); + +test('default map view', async ({ page }) => { + // Wait until at least one extract marker has rendered (they get a name + // asynchronously once the layer loads). + await page.locator('.leaflet-extracts-pane [role="button"]').first().waitFor(); + await expectNoA11yViolations(page, 'default map view'); +}); + +test('settings drawer (all sections expanded)', async ({ page }) => { + await page.getByRole('button', { name: 'Settings' }).click(); + // Desktop viewport opens every section; expand any that are still collapsed. + for (const header of await page.locator('.p-accordionheader[aria-expanded="false"]').all()) { + await header.click(); + } + await page.locator('.p-drawer').waitFor(); + await expectNoA11yViolations(page, 'settings drawer'); +}); + +test('map-selector flyout', async ({ page }) => { + await page.getByRole('button', { name: 'Map', exact: true }).click(); + await page.locator('.layer-rail__flyout').waitFor(); + await expectNoA11yViolations(page, 'map-selector flyout'); +}); + +test('player layer flyout', async ({ page }) => { + await page.getByRole('button', { name: 'Player', exact: true }).click(); + await page.locator('.layer-rail__flyout').waitFor(); + await expectNoA11yViolations(page, 'player layer flyout'); +}); diff --git a/apps/client/package.json b/apps/client/package.json index 058fe46..90f514b 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:a11y": "playwright test", "typecheck": "vue-tsc -b --noEmit", "lint": "eslint src", "lint:fix": "eslint src --fix" @@ -32,7 +33,9 @@ "zod": "^3.24.1" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@intlify/unplugin-vue-i18n": "^11.2.3", + "@playwright/test": "^1.61.0", "@primevue/auto-import-resolver": "^4.5.5", "@tailwindcss/vite": "^4.3.0", "@types/leaflet": "^1.9.15", @@ -40,8 +43,8 @@ "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.7.0", "jsdom": "^25.0.1", - "tailwindcss": "^4.3.0", "rollup-plugin-visualizer": "^7.0.1", + "tailwindcss": "^4.3.0", "typescript": "^5.7.2", "unplugin-auto-import": "^21.0.0", "unplugin-vue-components": "^32.1.0", diff --git a/apps/client/playwright.config.ts b/apps/client/playwright.config.ts new file mode 100644 index 0000000..b18e123 --- /dev/null +++ b/apps/client/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Accessibility (axe-core) test runner. Local-only by design — there is no CI + * job; run it on demand with `pnpm --filter @raidmate/client test:a11y`. + * + * Tests live in `e2e/` (outside `src/`) so Vitest — which globs `src/**` — + * never tries to run them. `webServer` boots the Vite dev server itself, or + * reuses one already on :5173. + */ +export default defineConfig({ + testDir: './e2e', + // Serial, single browser: this Windows box intermittently crashes child + // processes with STATUS_ACCESS_VIOLATION (0xC0000005) when several Chromium + // instances spawn at once (same flakiness documented for the Rust build). + // One worker + a retry keeps the run reliable; the suite is tiny so the + // wall-clock cost is negligible. + workers: 1, + retries: 1, + // `list` keeps output in the terminal; no HTML report server to leave hanging. + reporter: [['list']], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/client/src/features/airdrop/components/AirdropStatusBanner.vue b/apps/client/src/features/airdrop/components/AirdropStatusBanner.vue index 7c8363f..3ef0c03 100644 --- a/apps/client/src/features/airdrop/components/AirdropStatusBanner.vue +++ b/apps/client/src/features/airdrop/components/AirdropStatusBanner.vue @@ -68,6 +68,8 @@ const severity = computed<'info' | 'warn' | 'success'>(() => { @@ -266,6 +270,10 @@ function stepFloor(delta: number): void { background-color: var(--p-surface-800); color: var(--p-surface-0); } +.rail-btn:focus-visible { + outline: 2px solid var(--p-primary-400); + outline-offset: 2px; +} .rail-btn--active { background-color: color-mix(in srgb, var(--p-primary-500) 22%, transparent); color: var(--p-primary-300); diff --git a/apps/client/src/features/map/components/MapView.vue b/apps/client/src/features/map/components/MapView.vue index 35343b8..5be7d73 100644 --- a/apps/client/src/features/map/components/MapView.vue +++ b/apps/client/src/features/map/components/MapView.vue @@ -15,12 +15,16 @@ const emit = defineEmits<{ (e: 'mapError', err: string | null): void; }>(); -const { locale } = useI18n(); +const { t, locale } = useI18n(); const { localizedMapName } = useMapI18n(); const info = mapInfo(props.mapCode); const mapContainer = ref(null); +// Text alternative for the visual map (WCAG 1.1.1). role="application" tells +// screen readers to pass arrow keys through to Leaflet's keyboard pan/zoom. +const mapLabel = computed(() => t('a11y.mapRegion', { name: localizedMapName(props.mapCode) })); + const { map, initialZoom, @@ -79,6 +83,6 @@ watch(mapError, (err) => emit('mapError', err)); diff --git a/apps/client/src/features/map/layers/extracts/tooltip.ts b/apps/client/src/features/map/layers/extracts/tooltip.ts index d7b67a8..56aab48 100644 --- a/apps/client/src/features/map/layers/extracts/tooltip.ts +++ b/apps/client/src/features/map/layers/extracts/tooltip.ts @@ -25,6 +25,31 @@ function escapeHtml(s: string): string { * Co-located extracts with different names (Customs' Dorms V-Ex + Old Road * Gate) give multiple rows, each tagged by its own faction colour. */ +/** + * Plain-text accessible name for a marker (WCAG 4.1.2 / 2.4.4) — Leaflet makes + * each extract a focusable role="button", so it needs a name. One clause per + * distinct name with its faction(s): "Dorms V-Ex (PMC); Old Road Gate (Scav)". + */ +export function buildAriaLabel( + entries: ReadonlyArray, + factionLabel: (f: FactionKey) => string, +): string { + const byName = new Map(); + const order: string[] = []; + for (const e of entries) { + let bucket = byName.get(e.name); + if (!bucket) { + bucket = []; + byName.set(e.name, bucket); + order.push(e.name); + } + bucket.push(e.faction); + } + return order + .map((name) => `${name} (${byName.get(name)!.map(factionLabel).join(', ')})`) + .join('; '); +} + export function buildTooltipHtml(entries: ReadonlyArray): string { const factionsByName = new Map(); // Preserve first-seen name order so the layout matches FACTION_ORDER input. diff --git a/apps/client/src/features/map/layers/extracts/useExtractsLayer.ts b/apps/client/src/features/map/layers/extracts/useExtractsLayer.ts index 1f243e2..30cd221 100644 --- a/apps/client/src/features/map/layers/extracts/useExtractsLayer.ts +++ b/apps/client/src/features/map/layers/extracts/useExtractsLayer.ts @@ -1,7 +1,7 @@ import L, { type Marker, type LayerGroup } from 'leaflet'; import { FACTION_COLORS } from '@shared/maps'; import { makeIcon } from './icon'; -import { buildTooltipHtml, sortedEntries, type ExtractEntry } from './tooltip'; +import { buildAriaLabel, buildTooltipHtml, sortedEntries, type ExtractEntry } from './tooltip'; import { createEdgeIndicators, type EdgeArrow } from './useEdgeIndicators'; import { extractsForMap } from '@/features/map/data/extracts'; import { useMapSettingsStore } from '@/features/map/store'; @@ -70,6 +70,9 @@ export function useExtractsLayer(ctx: MapLayerContext): void { const filtered = effectiveEntries(entry); if (filtered.length > 0) { const factions = filtered.map((e) => e.faction); + // Accessible name for Leaflet's role="button" marker (WCAG 4.1.2). + // Set on options so _initIcon re-applies it on every setIcon rebuild. + entry.marker.options.title = buildAriaLabel(filtered, (f) => t(`factions.${f}`)); entry.marker.setIcon(makeIcon(factions)); entry.marker.unbindTooltip(); entry.marker.bindTooltip(buildTooltipHtml(filtered), { diff --git a/apps/client/src/features/overlay/components/MapSwitchMenu.vue b/apps/client/src/features/overlay/components/MapSwitchMenu.vue index b9426ac..1d10c01 100644 --- a/apps/client/src/features/overlay/components/MapSwitchMenu.vue +++ b/apps/client/src/features/overlay/components/MapSwitchMenu.vue @@ -3,6 +3,8 @@ import { VISIBLE_MAP_CODES, type TarkovMapCode } from '@shared/maps'; import { useMapSettingsStore } from '@/features/map/store'; import { useMapI18n } from '@/features/map/composables/useMapI18n'; +const { t } = useI18n(); + // Right-click-the-map-name → quick map switcher. Same teleported-popup pattern // as MapQuickMenu (open(x,y) / outside-click / Esc / blur dismissal). const { mapCode } = storeToRefs(useMapSettingsStore()); @@ -63,6 +65,7 @@ defineExpose({ open, close }); class="fixed z-[2000] max-h-[80vh] w-52 origin-top-left overflow-y-auto rounded-md border border-surface-700 bg-surface-900/95 p-1 shadow-xl backdrop-blur" :style="{ left: position.x + 'px', top: position.y + 'px' }" role="menu" + :aria-label="t('a11y.selectMap')" >