Skip to content
Open

Dev #31

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 22 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ and `extractNames.<canonicalMapCode>.<key>` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<img>`; 2 or 3 factions = stacked `<img>`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 `<img>` `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 `<img src>`
because an external SVG can't be recoloured from outside. Single-faction =
one `<svg>`; 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.

Expand Down
73 changes: 73 additions & 0 deletions apps/client/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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');
});
5 changes: 4 additions & 1 deletion apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,16 +33,18 @@
"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",
"@types/node": "^22.10.2",
"@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",
Expand Down
33 changes: 33 additions & 0 deletions apps/client/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
Binary file removed apps/client/public/icons/extracts/extract_pmc.png
Binary file not shown.
Binary file removed apps/client/public/icons/extracts/extract_scav.png
Binary file not shown.
Binary file removed apps/client/public/icons/extracts/extract_shared.png
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const severity = computed<'info' | 'warn' | 'success'>(() => {
<template>
<div
v-if="store.phase !== 'idle'"
role="status"
aria-live="polite"
class="pointer-events-none absolute top-14 inset-x-3 z-[1000] flex justify-center"
>
<Message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ let currentRecorder: { cancel: () => void } | null = null;
:class="recording ? 'ring-2 ring-primary' : ''"
>
<template v-if="recording">
<span class="truncate opacity-70">{{ t('hotkeys.recordingPrompt') }}</span>
<span class="truncate opacity-70" role="status" aria-live="polite">
{{ t('hotkeys.recordingPrompt') }}
</span>
</template>
<template v-else>
<span v-for="(part, idx) in displayParts" :key="idx" class="inline-flex items-center">
Expand All @@ -117,7 +119,7 @@ let currentRecorder: { cancel: () => void } | null = null;
@click="startRecording"
/>
</div>
<p v-if="error" class="mt-1.5 text-[10px] leading-relaxed text-amber-400">
<p v-if="error" role="alert" class="mt-1.5 text-[10px] leading-relaxed text-amber-400">
{{ t(error === 'altgr' ? 'hotkeys.altgr' : 'hotkeys.invalid') }}
</p>
</div>
Expand Down
Loading
Loading