Skip to content
Merged
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
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,
},
});
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
14 changes: 14 additions & 0 deletions apps/client/src/features/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@
"quests": "Quests"
},
"close": "Close",
"retry": "Retry",
"mapError": "Map load error: {error}",
"a11y": {
"connectionStatus": "Connection status",
"connection": {
"open": "Connected",
"connecting": "Connecting",
"closed": "Disconnected",
"idle": "Idle"
},
"mapRegion": "{name} — interactive map",
"selectMap": "Select map",
"opensNewWindow": "opens in a new window"
},
"floorWheelHint": "Floor — Alt + scroll to change",
"floor": "Floor",
"factions": {
Expand Down
14 changes: 14 additions & 0 deletions apps/client/src/features/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@
"quests": "Квесты"
},
"close": "Закрыть",
"retry": "Повторить",
"mapError": "Ошибка загрузки карты: {error}",
"a11y": {
"connectionStatus": "Состояние соединения",
"connection": {
"open": "Подключено",
"connecting": "Подключение",
"closed": "Нет связи",
"idle": "Ожидание"
},
"mapRegion": "{name} — интерактивная карта",
"selectMap": "Выбор карты",
"opensNewWindow": "откроется в новом окне"
},
"floorWheelHint": "Уровень — Alt + колесо для смены",
"floor": "Уровень",
"factions": {
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/features/i18n/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export const useI18nStore = defineStore('i18n', () => {
apiLang,
(lang) => {
void import('./index').then(({ setLocale }) => setLocale(lang));
// WCAG 3.1.1 Language of Page — keep <html lang> in step with the UI
// locale so screen readers and spell-checkers use the right language.
if (typeof document !== 'undefined') document.documentElement.lang = lang;
},
{ immediate: true },
);
Expand Down
24 changes: 16 additions & 8 deletions apps/client/src/features/map/components/LayerRail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,10 @@ function stepFloor(delta: number): void {
class="rail-btn"
:class="{ 'rail-btn--active': isMapOpen }"
:aria-label="t('map')"
:aria-expanded="isMapOpen"
@click="toggle('map')"
>
<i class="pi pi-map" />
<i class="pi pi-map" aria-hidden="true" />
</button>
<div class="bg-surface-700 my-0.5 h-px" />
<button
Expand All @@ -145,9 +146,10 @@ function stepFloor(delta: number): void {
:class="{ 'rail-btn--active': openId === c.key, 'rail-btn--empty': c.layers.length === 0 }"
:disabled="c.layers.length === 0"
:aria-label="c.label"
:aria-expanded="openId === c.key"
@click="toggle(c.key)"
>
<i class="pi" :class="c.icon" />
<i class="pi" :class="c.icon" aria-hidden="true" />
</button>

<!-- Floor stepper (multi-floor maps): glanceable current floor + ▲/▼. -->
Expand All @@ -160,7 +162,7 @@ function stepFloor(delta: number): void {
:aria-label="t('floor') + ' +'"
@click="stepFloor(-1)"
>
<i class="pi pi-chevron-up text-sm" />
<i class="pi pi-chevron-up text-sm" aria-hidden="true" />
</button>
<div
v-tooltip.right="{ value: t('floorWheelHint'), disabled: openId !== null }"
Expand All @@ -175,7 +177,7 @@ function stepFloor(delta: number): void {
:aria-label="t('floor') + ' -'"
@click="stepFloor(1)"
>
<i class="pi pi-chevron-down text-sm" />
<i class="pi pi-chevron-down text-sm" aria-hidden="true" />
</button>
</template>

Expand Down Expand Up @@ -205,6 +207,7 @@ function stepFloor(delta: number): void {
<div class="flex items-center gap-2">
<ToggleSwitch
:model-value="layerVis(l.id).value"
:aria-label="t(l.titleKey)"
@update:model-value="(v: boolean) => (layerVis(l.id).value = v)"
/>
<span class="flex-1 truncate text-sm">{{ t(l.titleKey) }}</span>
Expand All @@ -213,10 +216,11 @@ 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"
>
<i class="pi pi-cog text-sm" />
<i class="pi pi-cog text-sm" aria-hidden="true" />
</button>
</div>
<div v-if="l.settingsComponent && expanded === l.id" class="mt-2 pl-1">
Expand All @@ -234,8 +238,8 @@ function stepFloor(delta: number): void {
v-if="locked && hasFloors"
class="sa-bottom sa-left border-surface-700 bg-surface-900/85 absolute z-[1100] flex items-center gap-1.5 rounded-md border px-2 py-1 backdrop-blur"
>
<i class="pi pi-clone text-[10px] opacity-50" />
<span class="text-xs font-semibold tabular-nums">{{ currentFloorLabel }}</span>
<i class="pi pi-clone text-[10px] opacity-50" aria-hidden="true" />
<span class="text-xs font-semibold tabular-nums">{{ t('floor') }} {{ currentFloorLabel }}</span>
</div>
</template>

Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions apps/client/src/features/map/components/MapView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>(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,
Expand Down Expand Up @@ -79,6 +83,6 @@ watch(mapError, (err) => emit('mapError', err));
</script>

<template>
<div ref="mapContainer" class="absolute inset-0 z-0" />
<div ref="mapContainer" role="application" :aria-label="mapLabel" class="absolute inset-0 z-0" />
<LayerRail :floors="info.floors" :current-floor="currentFloor" @select-floor="setActiveFloor" />
</template>
25 changes: 25 additions & 0 deletions apps/client/src/features/map/layers/extracts/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtractEntry>,
factionLabel: (f: FactionKey) => string,
): string {
const byName = new Map<string, FactionKey[]>();
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<ExtractEntry>): string {
const factionsByName = new Map<string, FactionKey[]>();
// Preserve first-seen name order so the layout matches FACTION_ORDER input.
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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), {
Expand Down
Loading