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/CLAUDE.md b/CLAUDE.md index d968e57..e16eab9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,10 @@ and `extractNames..` under no strings — adding a language is a locale-only change. `factions` is an array because one physical exit can serve multiple sides -(PMC + Scav share the same door). At render time the client **merges +(PMC + Scav share the same door). `transit` is a fourth pseudo-faction for +map-to-map transit points — not a real side (PMC-only mechanic), but riding +the faction pipeline gives icons/tooltips/filter/auto-hide for free; the +destination map lives only in the i18n display name, not in the data. At render time the client **merges co-located extracts** that round to the same `(x, z)` bucket — Customs has dorms V-Ex (PMC) and old road gate (Scav) ~1m apart, they become a single composite marker. See "Map rendering layers" below for how that composite @@ -190,7 +193,11 @@ key back in the running app. (derived from registry `subgroup`: today only `player`, with `loot`/`quests` dimmed future icons). Clicking a category icon opens a flyout listing its layers: each row has a visibility `ToggleSwitch`, layer name, and a gear button - that expands that layer's settings component inline. Floor stepper at the rail's + that expands that layer's settings component inline. A layer may also register + a `filterComponent` — quick controls rendered always-expanded under its row + while the layer is visible, no gear click (extracts: the faction toggles, with + factions that have no exits on the current map auto-hidden via + `factionsForMap`). Floor stepper at the rail's bottom (multi-floor maps only) is a vertical ▲/level/▼. Rail hides when the overlay is click-through-locked. When locked on a multi-floor map, a compact read-only floor chip appears bottom-left. Flyouts position relative to the rail @@ -237,24 +244,21 @@ vertical span is pushed out to the rail's right edge (RAIL_GAP px beyond). Measu each frame via `.layer-rail` rect so arrows flow dynamically as the rail animates in/out on lock transitions. -Extract markers are `L.divIcon` instances (not `L.icon`) — the composite -PNG split via CSS `clip-path: polygon(...)` is built inline as HTML. -Single-faction = one ``; 2 or 3 factions = stacked ``s with `/`- -diagonal clip slices (area-balanced thirds for the 3-faction case). The -icon reacts to the faction filter dynamically: turning Scav off on a -PMC+Scav exit re-renders the icon as a clean PMC. Tailwind's preflight -gives `` `max-width: 100%` which collapses the icon to 0 inside -Leaflet's positioned wrapper, so `styles.css` keeps an override: - -``` -.leaflet-extracts-pane .extract-icon-divicon img { - max-width: none !important; max-height: none !important; -} -``` +Extract markers are `L.divIcon` instances (not `L.icon`) — the icon is +**inline SVG generated in `icon.ts`** (one hand-drawn shield+runner shape, +interior recoloured per faction from `FACTION_COLORS`; no static image +assets, nothing under `public/icons/`). Inline rather than `` +because an external SVG can't be recoloured from outside. Single-faction = +one ``; 2 or 3 factions = stacked recoloured copies with `/`-diagonal +CSS `clip-path` slices (area-balanced thirds for the 3-faction case; ≥4 +co-located kinds show the first three per `FACTION_ORDER`). The icon +reacts to the faction filter dynamically: turning Scav off on a PMC+Scav +exit re-renders the icon as a clean PMC. Tooltip is multi-row (one row per distinct name), each row carries a -faction-coloured left stripe via `.extract-tooltip-row--{pmc|scav|shared}` -or `.extract-tooltip-row--multi` when factions share a name. `direction: +faction-coloured left stripe — the colour is inlined from `FACTION_COLORS` +in `tooltip.ts` (no per-faction CSS classes); rows where multiple factions +share a name keep the neutral stripe from the base CSS rule. `direction: 'top'` keeps the tooltip above the icon; `tooltipAnchor` inside the icon's top edge gives a few pixels of overlap for visual cohesion. 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/public/icons/extracts/extract_pmc.png b/apps/client/public/icons/extracts/extract_pmc.png deleted file mode 100644 index b6d33bb..0000000 Binary files a/apps/client/public/icons/extracts/extract_pmc.png and /dev/null differ diff --git a/apps/client/public/icons/extracts/extract_scav.png b/apps/client/public/icons/extracts/extract_scav.png deleted file mode 100644 index e2ead3a..0000000 Binary files a/apps/client/public/icons/extracts/extract_scav.png and /dev/null differ diff --git a/apps/client/public/icons/extracts/extract_shared.png b/apps/client/public/icons/extracts/extract_shared.png deleted file mode 100644 index 84d0da9..0000000 Binary files a/apps/client/public/icons/extracts/extract_shared.png and /dev/null differ 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'>(() => {
void } | null = null; :class="recording ? 'ring-2 ring-primary' : ''" > @@ -205,6 +207,7 @@ function stepFloor(delta: number): void {
{{ t(l.titleKey) }} @@ -213,12 +216,18 @@ function stepFloor(delta: number): void { type="button" class="hover:bg-surface-800 flex h-8 w-8 shrink-0 items-center justify-center rounded opacity-70 hover:opacity-100" :class="{ 'text-primary-400 bg-surface-800 !opacity-100': expanded === l.id }" - :aria-label="t('settings')" + :aria-label="`${t(l.titleKey)} – ${t('settings')}`" + :aria-expanded="expanded === l.id" @click="expanded = expanded === l.id ? null : l.id" > - +