a11y: audit phase 3 — keyboard access, reduced motion, focus management
The site claimed comprehensive accessibility. It had a Level A keyboard failure, no reduced-motion support of any kind, and a dialog whose ARIA actively lied about the page state. Keyboard access to nodes (WCAG 2.1.1, Level A): - Nodes are PixiJS Containers with no DOM representation, so there was no way to reach or activate one by keyboard. The only keyboard vocabulary was pan/zoom/reset. New TreeNavigator.svelte renders the same data.ts as a real WAI-ARIA tree: roving tabindex, Up/Down traversal, Right/Left to expand and collapse, Home/End, Enter/Space to open the same modal a click opens. Focusing a row pans the canvas to that node so the two stay in sync. - The canvas advertised role="application", which tells screen readers to suppress their own navigation and forward every key — appropriate only for a widget implementing a full keyboard model, which it did not. Now role="img" with a description, still focusable for pan/zoom. - .sr-only content is revealed on :focus-within. Previously ~20 links were in the tab order while clipped to 1x1px, so a sighted keyboard user tabbed into nothing (WCAG 2.4.7). Added a skip link as the first focusable element. Focus management: - The modal set aria-modal="true" — telling AT that everything outside does not exist — while focus stayed on the canvas behind it and Tab walked the hidden text version underneath. Focus now moves into the dialog on open, is trapped there, and is restored to the trigger on close. - Removed two onkeydown Escape handlers (modal + stats backdrop) that could never fire: a role="presentation" div with no tabindex is never a keydown target. Their aria-labels were discarded by the role too. - The 300ms mobile touch-bleed guard was applied to every close path, so Escape or the X immediately after opening silently did nothing. It now guards only outside-dismissal, which is what it was for. Reduced motion (WCAG 2.3.3 / 2.2.2): - Previously respected nowhere, on a site built from a 4.5s forced zoom, an ~8s staggered entrance, ~480 looping tweens and a continuous dust ticker. The entrance now jumps to the finished state, the dust ticker is not registered, and all six ambient loops route through one guard that simply never creates them. Plus the CSS media query the GSAP tweens can't reach. Other: - StatsPanel used aria-hidden while containing tabindex="0" children — an axe aria-hidden-focus violation. Now `inert`, which removes it from the a11y tree and the tab order together. - Branch headers were div+role="button" handling Enter but not Space. Now real buttons with aria-controls. - The global keydown listener had no scope guard: it preventDefault()ed arrow keys unconditionally, so the 380px scrollable stats panel could not be scrolled by keyboard, and Ctrl combos were not excluded. Ctrl+wheel (browser and trackpad zoom) is no longer swallowed either. - Focus rings lived in SkillTree's *scoped* style block, so they never reached NodeModal or StatsPanel children. Moved to app.css. - Second <h1> in StatsPanel demoted; the page heading is SeoContent's. - getBeamIntensity used a truthiness check on yearsOfExperience, so 0 fell through to the default. Tests 16 -> 27: reduced-motion helper (all four branches), hierarchy building including cycle/orphan handling, and a data-integrity check that every parentId resolves to a real node. Verified in the production build: Tab reveals the text version with a visible focus ring; arrow keys traverse the tree and expand/collapse (rows 5->6); Enter opens the modal with focus landing on Close; Shift+Tab stays trapped; Escape restores focus to the originating tree item. Not verified end-to-end: the reduced-motion JS branch. This environment can't emulate the media query — patching matchMedia after navigation always races module init — so that branch is covered by unit tests and review, not observation. Worth a manual pass with DevTools > Rendering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f83a317520
commit
75d0584952
13 files changed
+749
-56
No files matched your search
@@ -4,6 +4,7 @@
|
||||
import { Container } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||
import { prefersReducedMotion } from '$lib/config';
|
||||
import { updateParallax } from './interaction';
|
||||
import type { SkillTreeController } from './controller';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
@@ -19,6 +20,31 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
|
||||
const { isMobile } = opts;
|
||||
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
|
||||
|
||||
// Reduced motion: show the finished tree immediately. No zoom, no stagger,
|
||||
// and critically no `repeat: -1` pulses — a static scene, not a faster one.
|
||||
if (prefersReducedMotion()) {
|
||||
treeContainer.scale.set(endScale);
|
||||
|
||||
for (const obj of nodeObjects) {
|
||||
obj.graphics.alpha = 1;
|
||||
obj.graphics.scale.set(1);
|
||||
obj.glowGraphics.alpha = 1;
|
||||
if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static';
|
||||
}
|
||||
|
||||
for (const line of lineObjects) {
|
||||
line.graphics.alpha = 1;
|
||||
line.glowGraphics.alpha = 1;
|
||||
line.plasmaBeam.alpha = 1;
|
||||
}
|
||||
|
||||
const toggle = document.querySelector<HTMLElement>('.stats-panel-toggle');
|
||||
if (toggle) toggle.style.opacity = '1';
|
||||
|
||||
opts.onTitleDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Responsive animation timing
|
||||
const titleDuration = isMobile ? 1.5 : 2.5;
|
||||
const zoomDuration = isMobile ? 2.5 : 4.5;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Application, Graphics, Container } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
import { nebulaPalette } from '$lib/theme';
|
||||
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
||||
import { PARTICLE_COUNTS, byDevice, prefersReducedMotion } from '$lib/config';
|
||||
import type { BackgroundLayers } from './types';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
@@ -24,6 +24,18 @@ export function createBackground(
|
||||
|
||||
// Everything below allocates `repeat: -1` ambient tweens; building it inside
|
||||
// the lifecycle's GSAP context makes all of them killable on teardown.
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
/**
|
||||
* Every animation in this file is an infinite ambient loop (twinkle, drift,
|
||||
* nebula breathing). Routing them through one helper means reduced motion
|
||||
* never creates them, rather than creating and immediately killing them.
|
||||
*/
|
||||
function ambient(target: object, vars: gsap.TweenVars) {
|
||||
if (reduceMotion) return;
|
||||
ambient(target, vars);
|
||||
}
|
||||
|
||||
life.animate(() => {
|
||||
const screenWidth = app.screen.width;
|
||||
const screenHeight = app.screen.height;
|
||||
@@ -87,7 +99,7 @@ export function createBackground(
|
||||
nebulaContainer.addChild(nebulaCloud);
|
||||
|
||||
// Animate nebula pulsing
|
||||
gsap.to(nebulaCloud, {
|
||||
ambient(nebulaCloud, {
|
||||
alpha: 0.6,
|
||||
duration: 3 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
@@ -96,7 +108,7 @@ export function createBackground(
|
||||
delay: Math.random() * 2
|
||||
});
|
||||
|
||||
gsap.to(nebulaCloud.scale, {
|
||||
ambient(nebulaCloud.scale, {
|
||||
x: 1.1,
|
||||
y: 1.1,
|
||||
duration: 4 + Math.random() * 2,
|
||||
@@ -121,7 +133,7 @@ export function createBackground(
|
||||
star.y = Math.random() * screenHeight;
|
||||
starContainer.addChild(star);
|
||||
|
||||
gsap.to(star, {
|
||||
ambient(star, {
|
||||
alpha: alpha * 0.3,
|
||||
duration: 1 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
@@ -148,7 +160,7 @@ export function createBackground(
|
||||
star.y = Math.random() * screenHeight;
|
||||
starContainer.addChild(star);
|
||||
|
||||
gsap.to(star, {
|
||||
ambient(star, {
|
||||
alpha: alpha * 0.4,
|
||||
duration: 0.5 + Math.random() * 1.5,
|
||||
ease: 'sine.inOut',
|
||||
@@ -173,7 +185,7 @@ export function createBackground(
|
||||
star.y = Math.random() * screenHeight;
|
||||
starContainer.addChild(star);
|
||||
|
||||
gsap.to(star.scale, {
|
||||
ambient(star.scale, {
|
||||
x: 1.3,
|
||||
y: 1.3,
|
||||
duration: 2 + Math.random() * 2,
|
||||
@@ -208,7 +220,7 @@ export function createBackground(
|
||||
vy: (Math.random() - 0.5) * 0.2
|
||||
});
|
||||
|
||||
gsap.to(dust, {
|
||||
ambient(dust, {
|
||||
alpha: 0.3,
|
||||
duration: 3 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
@@ -233,8 +245,12 @@ export function createBackground(
|
||||
else if (g.y > screenHeight + 20) g.y = -20;
|
||||
}
|
||||
}
|
||||
app.ticker.add(driftDust);
|
||||
life.add(() => app.ticker.remove(driftDust));
|
||||
// Drifting dust is continuous ambient motion — skip it entirely rather
|
||||
// than run it slower.
|
||||
if (!reduceMotion) {
|
||||
app.ticker.add(driftDust);
|
||||
life.add(() => app.ticker.remove(driftDust));
|
||||
}
|
||||
|
||||
// ========== VIGNETTE OVERLAY ==========
|
||||
vignette = new Graphics();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// handlers read/write it by reference. attach* functions return a cleanup that
|
||||
// removes the window-level listeners they added.
|
||||
import { gsap } from 'gsap';
|
||||
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
|
||||
import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion } from '$lib/config';
|
||||
import type { SkillTreeController } from './controller';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
@@ -198,6 +198,9 @@ export function attachPanZoom(
|
||||
app.canvas,
|
||||
'wheel',
|
||||
(e: WheelEvent) => {
|
||||
// Ctrl+wheel is the browser's page-zoom gesture (and trackpad pinch);
|
||||
// swallowing it broke zooming for anyone who relies on it.
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
const oldScale = treeContainer.scale.x;
|
||||
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
|
||||
@@ -214,6 +217,28 @@ export function attachPanZoom(
|
||||
return { flushTooltip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre the view on a node in tree-space. Used by the accessible tree
|
||||
* navigator so keyboard focus and the canvas stay in sync.
|
||||
*/
|
||||
export function panToNode(ctrl: SkillTreeController, x: number, y: number, life: Lifecycle) {
|
||||
const { app, treeContainer } = ctrl;
|
||||
const scale = treeContainer.scale.x;
|
||||
const targetX = app.screen.width / 2 - x * scale;
|
||||
const targetY = app.screen.height / 2 - y * scale;
|
||||
|
||||
life.animate(() => {
|
||||
gsap.to(treeContainer, {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
duration: prefersReducedMotion() ? 0 : 0.4,
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface KeyboardOpts {
|
||||
onToggleStats: () => void;
|
||||
onCloseModal: () => void;
|
||||
@@ -221,6 +246,11 @@ interface KeyboardOpts {
|
||||
isStatsVisible: () => boolean;
|
||||
}
|
||||
|
||||
/** Narrow an event target to an Element, or null. */
|
||||
function target0(e: Event): Element | null {
|
||||
return e.target instanceof Element ? e.target : null;
|
||||
}
|
||||
|
||||
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
|
||||
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
|
||||
const { app, treeContainer } = ctrl;
|
||||
@@ -228,6 +258,39 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
||||
const maxZoom = ZOOM.max;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Don't hijack keys the browser or another widget owns.
|
||||
//
|
||||
// - Modifier combos belong to the browser (Ctrl+Plus/Minus zoom, etc).
|
||||
// - Typing in a field must never pan the canvas.
|
||||
// - Arrow keys inside a scrollable overlay (the 380px stats panel, the
|
||||
// modal body) must scroll it, not pan the tree behind it. Previously
|
||||
// every arrow press called preventDefault() unconditionally, so those
|
||||
// regions could not be scrolled by keyboard at all.
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
// `instanceof Element`, not a truthiness check: a keydown dispatched on
|
||||
// `window` has `window` as its target, which has no tagName/closest — and
|
||||
// calling closest() on it throws, killing the whole handler (Escape
|
||||
// included).
|
||||
const target = e.target instanceof Element ? target0(e) : null;
|
||||
if (target) {
|
||||
const tag = target.tagName;
|
||||
if (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
tag === 'SELECT' ||
|
||||
(target as HTMLElement).isContentEditable ||
|
||||
target.closest('#stats-panel, [role="dialog"]')
|
||||
) {
|
||||
// Escape still needs to work from inside those regions.
|
||||
if (e.key === 'Escape') {
|
||||
if (opts.isModalVisible()) opts.onCloseModal();
|
||||
else if (opts.isStatsVisible()) opts.onToggleStats();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle stats panel with 'C' key
|
||||
if (e.key === 'c' || e.key === 'C') {
|
||||
if (!opts.isModalVisible()) {
|
||||
|
||||
Reference in new issue
Block a user