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:
TehRiehlDealandClaude Opus 5 committed 2026-08-09 01:03:36 -07:00
1 parent f83a317520
commit 75d0584952
13 files changed
+747 -54

No files matched your search

+65
View File
@@ -32,10 +32,75 @@ body {
background: var(--bg-space); background: var(--bg-space);
} }
/*
* Focus indicators. These used to live in SkillTree.svelte's *scoped* style
* block, so Svelte's scoping meant they never reached NodeModal or StatsPanel
* children — those fell back to the UA default outline, which is low-contrast
* on this background. Global is the only place this rule works.
*/
:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
border-radius: 2px;
}
/*
* Reduced motion. The JS side (see config.prefersReducedMotion) skips the
* entrance and never creates the ambient GSAP loops; this covers the CSS
* animations and transitions those can't reach.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* /*
* Visually-hidden but available to screen readers and crawlers. Used for the * Visually-hidden but available to screen readers and crawlers. Used for the
* text version of the portfolio (the interactive content is canvas-rendered). * text version of the portfolio (the interactive content is canvas-rendered).
*/ */
/*
* ...but focusable content inside it must become visible when focused, or a
* sighted keyboard user tabs through ~20 invisible stops with no idea where
* focus is (WCAG 2.4.7). Standard sr-only-focusable treatment.
*/
.sr-only:focus-within {
position: fixed;
top: 0;
left: 0;
z-index: 2000;
width: auto;
height: auto;
max-width: min(90vw, 60rem);
max-height: 90vh;
margin: 0;
overflow: auto;
clip: auto;
white-space: normal;
padding: 1.5rem;
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
border: 1px solid var(--color-gold);
border-radius: 0 0 8px 0;
color: #e8e8f0;
line-height: 1.6;
}
.sr-only:focus-within a {
color: var(--color-gold);
}
.sr-only:focus-within h1,
.sr-only:focus-within h2,
.sr-only:focus-within h3 {
color: var(--color-gold);
margin: 1rem 0 0.5rem;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;
+72 -12
View File
@@ -2,28 +2,84 @@
import type { TreeNode } from '$lib/data'; import type { TreeNode } from '$lib/data';
import { categoryColorHex } from '$lib/theme'; import { categoryColorHex } from '$lib/theme';
const { node, visible, onClose }: { node: TreeNode; visible: boolean; onClose: () => void } = const {
$props(); node,
visible,
onClose,
onDismiss
}: {
node: TreeNode;
visible: boolean;
/** Explicit close (✕ button) — always immediate. */
onClose: () => void;
/** Outside/backdrop dismissal — subject to the mobile touch-bleed guard. */
onDismiss: () => void;
} = $props();
let dialogEl = $state<HTMLDivElement | null>(null);
const FOCUSABLE =
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
/**
* Move focus into the dialog on open and restore it on close.
*
* Without this, `aria-modal="true"` was lying: it tells assistive tech that
* everything outside the dialog does not exist, while focus stayed on the
* canvas behind it and Tab walked the whole hidden text version underneath.
*/
$effect(() => {
if (!visible || !dialogEl) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const first = dialogEl.querySelector<HTMLElement>(FOCUSABLE);
(first ?? dialogEl).focus({ preventScroll: true });
return () => previouslyFocused?.focus?.({ preventScroll: true });
});
/** Keep Tab inside the dialog while it's open. */
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab' || !dialogEl) return;
const items = [...dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === dialogEl)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
</script> </script>
<!--
Backdrop is presentational: click-to-close only. It previously carried an
onkeydown Escape handler, but a role="presentation" div with no tabindex can
never be a keydown target, so that handler was unreachable. Escape is handled
by the global key listener (see pixi/interaction.ts), and Tab is trapped by
the dialog below.
-->
<div class="modal-backdrop" class:visible onclick={onDismiss} role="presentation">
<div <div
class="modal-backdrop" bind:this={dialogEl}
class:visible
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
aria-label="Modal backdrop. Click to close or press Escape."
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="modal" class="modal"
class:visible class:visible
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={trapFocus}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-title" aria-labelledby="modal-title"
aria-describedby="modal-description" aria-describedby="modal-description"
tabindex="-1"
style="--node-color: {categoryColorHex[node.category]}" style="--node-color: {categoryColorHex[node.category]}"
> >
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button> <button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
@@ -219,6 +275,10 @@
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.modal:focus {
outline: none;
}
.modal { .modal {
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%); background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
border: 1px solid var(--node-color); border: 1px solid var(--node-color);
+41 -17
View File
@@ -9,6 +9,7 @@
import Tooltip from './Tooltip.svelte'; import Tooltip from './Tooltip.svelte';
import NodeModal from './NodeModal.svelte'; import NodeModal from './NodeModal.svelte';
import StatsPanel from './StatsPanel.svelte'; import StatsPanel from './StatsPanel.svelte';
import TreeNavigator from './TreeNavigator.svelte';
import { MOBILE_BREAKPOINT } from '$lib/config'; import { MOBILE_BREAKPOINT } from '$lib/config';
import { import {
BRANCH_IDS, BRANCH_IDS,
@@ -18,16 +19,18 @@
countByProficiency countByProficiency
} from '$lib/utils/stats'; } from '$lib/utils/stats';
import { createBackground } from '$lib/pixi/background'; import { createBackground } from '$lib/pixi/background';
import { createController } from '$lib/pixi/controller'; import { createController, type SkillTreeController } from '$lib/pixi/controller';
import { createLines } from '$lib/pixi/lines'; import { createLines } from '$lib/pixi/lines';
import { createNodes } from '$lib/pixi/nodes'; import { createNodes } from '$lib/pixi/nodes';
import { attachPanZoom, attachKeyboard, updateParallax } from '$lib/pixi/interaction'; import { attachPanZoom, attachKeyboard, updateParallax, panToNode } from '$lib/pixi/interaction';
import { runEntranceAnimations } from '$lib/pixi/animation'; import { runEntranceAnimations } from '$lib/pixi/animation';
import { createLifecycle } from '$lib/pixi/lifecycle'; import { createLifecycle } from '$lib/pixi/lifecycle';
const { onRenderFail }: { onRenderFail?: () => void } = $props(); const { onRenderFail }: { onRenderFail?: () => void } = $props();
let container: HTMLDivElement; let container: HTMLDivElement;
// Set once the canvas is up; the tree navigator uses it to pan the camera.
let controllerRef: SkillTreeController | null = null;
let selectedNode = $state<TreeNode | null>(null); let selectedNode = $state<TreeNode | null>(null);
let modalVisible = $state(false); let modalVisible = $state(false);
let showWelcomeTitle = $state(true); let showWelcomeTitle = $state(true);
@@ -62,18 +65,23 @@
} }
function closeModal() { function closeModal() {
// Prevent closing if modal just opened (within 300ms)
// This fixes touch events "bleeding through" on mobile
if (Date.now() - modalOpenedAt < 300) {
return;
}
modalVisible = false; modalVisible = false;
setTimeout(() => { setTimeout(() => {
selectedNode = null; selectedNode = null;
}, 200); }, 200);
} }
/**
* Backdrop//outside dismissal only. The 300ms guard exists because a tap that
* opens the modal on mobile can bleed through to the backdrop underneath —
* but it used to gate *every* close path, so pressing Escape or the ✕ right
* after opening silently did nothing.
*/
function dismissModal() {
if (Date.now() - modalOpenedAt < 300) return;
closeModal();
}
function toggleStatsPanel() { function toggleStatsPanel() {
statsPanelVisible = !statsPanelVisible; statsPanelVisible = !statsPanelVisible;
if (statsPanelVisible && modalVisible) { if (statsPanelVisible && modalVisible) {
@@ -165,6 +173,8 @@
treeContainer.alpha = 1; treeContainer.alpha = 1;
const controller = createController(app, treeContainer, bg, endScale); const controller = createController(app, treeContainer, bg, endScale);
controllerRef = controller;
life.add(() => (controllerRef = null));
// Build the visualisation (connections first, then nodes on top) // Build the visualisation (connections first, then nodes on top)
controller.lineObjects = createLines(treeContainer); controller.lineObjects = createLines(treeContainer);
@@ -318,11 +328,24 @@
<div <div
bind:this={container} bind:this={container}
class="canvas-container" class="canvas-container"
role="application" role="img"
aria-label="Interactive skill tree visualization. Use arrow keys to pan, +/- to zoom, R to reset view, C to toggle character stats panel." aria-label="Skill tree visualisation. Arrow keys pan, plus and minus zoom, R resets the view, C toggles the character stats panel. For a navigable list of every entry, use the skill tree navigator."
tabindex="0" tabindex="0"
></div> ></div>
<!--
The keyboard/AT interface for the canvas. role="application" used to sit on
the div above, which tells screen readers to suppress their own navigation
and forward every key — appropriate only for a widget implementing a full
keyboard model, which the canvas does not. This does.
-->
<TreeNavigator
onActivate={openModal}
onFocusNode={(node) => {
if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
}}
/>
<!-- Loading Indicator for Lighthouse LCP --> <!-- Loading Indicator for Lighthouse LCP -->
{#if isLoading} {#if isLoading}
<LoadingIndicator /> <LoadingIndicator />
@@ -345,7 +368,12 @@
<!-- Modal Overlay --> <!-- Modal Overlay -->
{#if selectedNode} {#if selectedNode}
<NodeModal node={selectedNode} visible={modalVisible} onClose={closeModal} /> <NodeModal
node={selectedNode}
visible={modalVisible}
onClose={closeModal}
onDismiss={dismissModal}
/>
{/if} {/if}
<!-- Character Stats Panel (toggle button + backdrop + panel) --> <!-- Character Stats Panel (toggle button + backdrop + panel) -->
@@ -362,12 +390,8 @@
/> />
<style> <style>
/* Global focus indicators for accessibility */ /* Focus indicators are global now — see app.css. A scoped rule here could
*:focus-visible { never reach NodeModal or StatsPanel children. */
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
.canvas-container { .canvas-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
+31 -16
View File
@@ -40,27 +40,36 @@
<!-- Stats Panel Backdrop --> <!-- Stats Panel Backdrop -->
{#if visible} {#if visible}
<div <!-- Presentational click-to-close. The onkeydown this used to carry could
class="stats-panel-backdrop visible" never fire (role="presentation", no tabindex); Escape is global. -->
onclick={onToggle} <div class="stats-panel-backdrop visible" onclick={onToggle} role="presentation"></div>
onkeydown={(e) => e.key === 'Escape' && onToggle()}
role="presentation"
aria-label="Stats panel backdrop. Click to close or press Escape."
></div>
{/if} {/if}
<!-- Stats Panel --> <!-- Stats Panel -->
<!--
`inert` rather than aria-hidden: the panel is only translated off-screen, so
its focusable children stayed in the tab order while announcing as nothing —
a direct axe `aria-hidden-focus` violation. inert removes it from the
accessibility tree AND the tab order together.
-->
<div <div
id="stats-panel" id="stats-panel"
class="stats-panel" class="stats-panel"
class:visible class:visible
role="complementary" role="complementary"
aria-label="Character Statistics" aria-label="Character Statistics"
aria-hidden={!visible} inert={!visible}
> >
<!-- Character Card --> <!-- Character Card -->
<div class="character-card"> <div class="character-card">
<img src="/headshot.jpg" alt="Character Portrait" class="character-portrait" /> <img
src="/headshot.jpg"
alt=""
width="120"
height="120"
decoding="async"
class="character-portrait"
/>
<div class="character-stats-bar"> <div class="character-stats-bar">
<div class="stat-item"> <div class="stat-item">
@@ -70,7 +79,7 @@
</div> </div>
</div> </div>
<h1 class="character-name">Kevin Riehl</h1> <p class="character-name">Kevin Riehl</p>
<p class="character-title">{playerClass}</p> <p class="character-title">{playerClass}</p>
</div> </div>
@@ -107,13 +116,14 @@
<!-- Skill Branches --> <!-- Skill Branches -->
{#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)} {#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)}
<div class="skill-branch"> <div class="skill-branch">
<div <!-- A real <button>: the previous div+role="button" handled Enter but
not Space, which the ARIA button pattern requires. -->
<button
type="button"
class="branch-header" class="branch-header"
onclick={() => onToggleBranch(branchId)} onclick={() => onToggleBranch(branchId)}
onkeydown={(e) => e.key === 'Enter' && onToggleBranch(branchId)}
tabindex="0"
role="button"
aria-expanded={expandedBranches.has(branchId)} aria-expanded={expandedBranches.has(branchId)}
aria-controls="skill-list-{branchId}"
> >
<img <img
src={treeNodes.find((n) => n.id === branchId)?.icon || ''} src={treeNodes.find((n) => n.id === branchId)?.icon || ''}
@@ -123,10 +133,10 @@
<span class="branch-title">{branch.label}</span> <span class="branch-title">{branch.label}</span>
<span class="branch-count">({branch.skills.length})</span> <span class="branch-count">({branch.skills.length})</span>
<span class="expand-icon" class:expanded={expandedBranches.has(branchId)}>▼</span> <span class="expand-icon" class:expanded={expandedBranches.has(branchId)}>▼</span>
</div> </button>
{#if expandedBranches.has(branchId)} {#if expandedBranches.has(branchId)}
<div class="skill-list" transition:slide={{ duration: 300 }}> <div id="skill-list-{branchId}" class="skill-list" transition:slide={{ duration: 300 }}>
{#each branch.skills as skill (skill.id)} {#each branch.skills as skill (skill.id)}
<div class="skill-item"> <div class="skill-item">
{#if skill.icon} {#if skill.icon}
@@ -362,6 +372,11 @@
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
/* Reset the UA button defaults so this keeps the previous appearance. */
width: 100%;
text-align: left;
font: inherit;
color: inherit;
} }
.branch-header:hover { .branch-header:hover {
+225
View File
@@ -0,0 +1,225 @@
<script lang="ts">
// Accessible counterpart to the canvas.
//
// The skill tree is drawn entirely in WebGL: nodes are PixiJS Containers with
// no DOM representation, so before this there was no way to reach a node by
// keyboard or have one announced. The canvas advertised role="application",
// which tells screen readers to hand every key to the app — for a widget that
// implemented no keyboard model at all.
//
// This renders the same data.ts as a real WAI-ARIA tree: roving tabindex,
// arrow-key traversal, Enter/Space to open the same modal a click opens.
// Visually hidden until focused, then shown, so it doubles as a skip-target
// for sighted keyboard users instead of ~20 invisible tab stops.
import { treeNodes, type TreeNode } from '$lib/data';
import { buildHierarchy, flattenVisible } from '$lib/utils/tree';
import { categoryColorHex } from '$lib/theme';
import { SvelteSet } from 'svelte/reactivity';
const {
onActivate,
onFocusNode
}: {
/** Enter/Space on a node — opens the same modal as a canvas click. */
onActivate: (node: TreeNode) => void;
/** Focus moved to a node — pans the canvas so the two stay in sync. */
onFocusNode?: (node: TreeNode) => void;
} = $props();
const roots = buildHierarchy(treeNodes);
// SvelteSet, not $state(new Set()) — Svelte does not proxy a plain Set, so
// in-place mutation is untracked. (This is the same trap that silently broke
// the stats panel's expand/collapse.)
const expanded = new SvelteSet<string>(roots.map((r) => r.node.id));
let activeId = $state(roots[0]?.node.id ?? '');
const rows = $derived(flattenVisible(roots, (id) => expanded.has(id)));
function setExpanded(id: string, open: boolean) {
if (open) expanded.add(id);
else expanded.delete(id);
}
function focusRow(index: number) {
const row = rows[index];
if (!row) return;
activeId = row.branch.node.id;
document.getElementById(`tn-${row.branch.node.id}`)?.focus();
onFocusNode?.(row.branch.node);
}
function handleKeydown(e: KeyboardEvent, index: number) {
const { branch } = rows[index];
const hasChildren = branch.children.length > 0;
const isOpen = expanded.has(branch.node.id);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
focusRow(Math.min(index + 1, rows.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case 'ArrowRight':
e.preventDefault();
if (hasChildren && !isOpen) setExpanded(branch.node.id, true);
else if (hasChildren) focusRow(index + 1);
break;
case 'ArrowLeft': {
e.preventDefault();
if (hasChildren && isOpen) {
setExpanded(branch.node.id, false);
break;
}
// Otherwise move to the parent row.
const parentId = branch.node.parentId;
const parentIndex = rows.findIndex((r) => r.branch.node.id === parentId);
if (parentIndex !== -1) focusRow(parentIndex);
break;
}
case 'Home':
e.preventDefault();
focusRow(0);
break;
case 'End':
e.preventDefault();
focusRow(rows.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
onActivate(branch.node);
break;
}
}
</script>
<div class="tree-nav">
<h2 id="tree-nav-label" tabindex="-1">Skill tree navigator</h2>
<p class="tree-nav-hint">
Arrow keys move between entries, Right and Left expand and collapse, Enter opens details.
</p>
<ul role="tree" aria-labelledby="tree-nav-label">
{#each rows as { branch, level }, index (branch.node.id)}
{@const node = branch.node}
{@const hasChildren = branch.children.length > 0}
<li
role="treeitem"
id="tn-{node.id}"
aria-level={level}
aria-expanded={hasChildren ? expanded.has(node.id) : undefined}
aria-selected={activeId === node.id}
tabindex={activeId === node.id ? 0 : -1}
style="--node-color: {categoryColorHex[node.category]}; --level: {level}"
onkeydown={(e) => handleKeydown(e, index)}
onclick={() => onActivate(node)}
onfocus={() => {
activeId = node.id;
onFocusNode?.(node);
}}
>
<span class="tn-dot" aria-hidden="true"></span>
<span class="tn-label">{node.label}</span>
<span class="tn-category">{node.category}</span>
</li>
{/each}
</ul>
</div>
<style>
/*
* Visually hidden until something inside it takes focus. Same idea as a skip
* link: invisible to mouse users, but a sighted keyboard user gets a real,
* visible panel instead of focus vanishing into a 1x1px clip region.
*/
.tree-nav {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.tree-nav:focus-within {
position: fixed;
top: 0;
left: 0;
z-index: 1200;
width: min(24rem, 92vw);
height: auto;
max-height: 90vh;
margin: 0;
padding: 1rem;
overflow-y: auto;
clip: auto;
white-space: normal;
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
border: 1px solid var(--color-gold);
border-radius: 0 0 10px 0;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.tree-nav h2 {
margin: 0 0 0.25rem;
color: var(--color-gold);
font-size: 1rem;
}
.tree-nav-hint {
margin: 0 0 0.75rem;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.4;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
padding-left: calc(0.5rem + (var(--level) - 1) * 0.85rem);
border-radius: 6px;
color: #e8e8f0;
font-size: 0.85rem;
cursor: pointer;
}
li:hover,
li[aria-selected='true'] {
background: rgba(255, 255, 255, 0.06);
}
.tn-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-color);
}
.tn-label {
flex: 1 1 auto;
}
.tn-category {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, afterEach } from 'vitest';
import { prefersReducedMotion, byDevice } from './config';
const originalWindow = (globalThis as { window?: unknown }).window;
function stubMatchMedia(matches: boolean | null) {
(globalThis as { window?: unknown }).window =
matches === null
? {} // window exists but matchMedia doesn't (very old/embedded engines)
: { matchMedia: (media: string) => ({ matches, media }) };
}
afterEach(() => {
(globalThis as { window?: unknown }).window = originalWindow;
});
describe('prefersReducedMotion', () => {
it('is false when there is no window (SSR / prerender)', () => {
delete (globalThis as { window?: unknown }).window;
expect(prefersReducedMotion()).toBe(false);
});
it('is false when window has no matchMedia', () => {
stubMatchMedia(null);
expect(prefersReducedMotion()).toBe(false);
});
it('is true when the user asked for reduced motion', () => {
stubMatchMedia(true);
expect(prefersReducedMotion()).toBe(true);
});
it('is false when the user has not', () => {
stubMatchMedia(false);
expect(prefersReducedMotion()).toBe(false);
});
it('reads matchMedia at call time, not module load', () => {
stubMatchMedia(false);
expect(prefersReducedMotion()).toBe(false);
stubMatchMedia(true);
expect(prefersReducedMotion()).toBe(true);
});
});
describe('byDevice', () => {
it('picks the mobile value on mobile and the desktop value otherwise', () => {
expect(byDevice({ desktop: 'd', mobile: 'm' }, true)).toBe('m');
expect(byDevice({ desktop: 'd', mobile: 'm' }, false)).toBe('d');
});
});
+13
View File
@@ -56,6 +56,19 @@ export const TIMINGS = {
nodeStagger: { desktop: 0.08, mobile: 0.05 } nodeStagger: { desktop: 0.08, mobile: 0.05 }
} as const; } as const;
/**
* Whether the visitor has asked for reduced motion.
*
* This site is unusually motion-heavy — a multi-second forced zoom, a staggered
* entrance, ~480 looping tweens and a continuous dust ticker — and none of it
* was previously conditional. Read at call time, not module load, so it is
* correct under SSR/prerender and picks up an OS-level change on reload.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Helper: pick a desktop/mobile value. */ /** Helper: pick a desktop/mobile value. */
export function byDevice<T>(pair: { desktop: T; mobile: T }, isMobile: boolean): T { export function byDevice<T>(pair: { desktop: T; mobile: T }, isMobile: boolean): T {
return isMobile ? pair.mobile : pair.desktop; return isMobile ? pair.mobile : pair.desktop;
+26
View File
@@ -4,6 +4,7 @@
import { Container } from 'pixi.js'; import { Container } from 'pixi.js';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { getDistanceFromOrigin } from '$lib/utils/tree'; import { getDistanceFromOrigin } from '$lib/utils/tree';
import { prefersReducedMotion } from '$lib/config';
import { updateParallax } from './interaction'; import { updateParallax } from './interaction';
import type { SkillTreeController } from './controller'; import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle'; import type { Lifecycle } from './lifecycle';
@@ -19,6 +20,31 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
const { isMobile } = opts; const { isMobile } = opts;
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl; 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 // Responsive animation timing
const titleDuration = isMobile ? 1.5 : 2.5; const titleDuration = isMobile ? 1.5 : 2.5;
const zoomDuration = isMobile ? 2.5 : 4.5; const zoomDuration = isMobile ? 2.5 : 4.5;
+23 -7
View File
@@ -4,7 +4,7 @@
import { Application, Graphics, Container } from 'pixi.js'; import { Application, Graphics, Container } from 'pixi.js';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { nebulaPalette } from '$lib/theme'; 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 { BackgroundLayers } from './types';
import type { Lifecycle } from './lifecycle'; import type { Lifecycle } from './lifecycle';
@@ -24,6 +24,18 @@ export function createBackground(
// Everything below allocates `repeat: -1` ambient tweens; building it inside // Everything below allocates `repeat: -1` ambient tweens; building it inside
// the lifecycle's GSAP context makes all of them killable on teardown. // 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(() => { life.animate(() => {
const screenWidth = app.screen.width; const screenWidth = app.screen.width;
const screenHeight = app.screen.height; const screenHeight = app.screen.height;
@@ -87,7 +99,7 @@ export function createBackground(
nebulaContainer.addChild(nebulaCloud); nebulaContainer.addChild(nebulaCloud);
// Animate nebula pulsing // Animate nebula pulsing
gsap.to(nebulaCloud, { ambient(nebulaCloud, {
alpha: 0.6, alpha: 0.6,
duration: 3 + Math.random() * 2, duration: 3 + Math.random() * 2,
ease: 'sine.inOut', ease: 'sine.inOut',
@@ -96,7 +108,7 @@ export function createBackground(
delay: Math.random() * 2 delay: Math.random() * 2
}); });
gsap.to(nebulaCloud.scale, { ambient(nebulaCloud.scale, {
x: 1.1, x: 1.1,
y: 1.1, y: 1.1,
duration: 4 + Math.random() * 2, duration: 4 + Math.random() * 2,
@@ -121,7 +133,7 @@ export function createBackground(
star.y = Math.random() * screenHeight; star.y = Math.random() * screenHeight;
starContainer.addChild(star); starContainer.addChild(star);
gsap.to(star, { ambient(star, {
alpha: alpha * 0.3, alpha: alpha * 0.3,
duration: 1 + Math.random() * 2, duration: 1 + Math.random() * 2,
ease: 'sine.inOut', ease: 'sine.inOut',
@@ -148,7 +160,7 @@ export function createBackground(
star.y = Math.random() * screenHeight; star.y = Math.random() * screenHeight;
starContainer.addChild(star); starContainer.addChild(star);
gsap.to(star, { ambient(star, {
alpha: alpha * 0.4, alpha: alpha * 0.4,
duration: 0.5 + Math.random() * 1.5, duration: 0.5 + Math.random() * 1.5,
ease: 'sine.inOut', ease: 'sine.inOut',
@@ -173,7 +185,7 @@ export function createBackground(
star.y = Math.random() * screenHeight; star.y = Math.random() * screenHeight;
starContainer.addChild(star); starContainer.addChild(star);
gsap.to(star.scale, { ambient(star.scale, {
x: 1.3, x: 1.3,
y: 1.3, y: 1.3,
duration: 2 + Math.random() * 2, duration: 2 + Math.random() * 2,
@@ -208,7 +220,7 @@ export function createBackground(
vy: (Math.random() - 0.5) * 0.2 vy: (Math.random() - 0.5) * 0.2
}); });
gsap.to(dust, { ambient(dust, {
alpha: 0.3, alpha: 0.3,
duration: 3 + Math.random() * 2, duration: 3 + Math.random() * 2,
ease: 'sine.inOut', ease: 'sine.inOut',
@@ -233,8 +245,12 @@ export function createBackground(
else if (g.y > screenHeight + 20) g.y = -20; else if (g.y > screenHeight + 20) g.y = -20;
} }
} }
// Drifting dust is continuous ambient motion — skip it entirely rather
// than run it slower.
if (!reduceMotion) {
app.ticker.add(driftDust); app.ticker.add(driftDust);
life.add(() => app.ticker.remove(driftDust)); life.add(() => app.ticker.remove(driftDust));
}
// ========== VIGNETTE OVERLAY ========== // ========== VIGNETTE OVERLAY ==========
vignette = new Graphics(); vignette = new Graphics();
+64 -1
View File
@@ -4,7 +4,7 @@
// handlers read/write it by reference. attach* functions return a cleanup that // handlers read/write it by reference. attach* functions return a cleanup that
// removes the window-level listeners they added. // removes the window-level listeners they added.
import { gsap } from 'gsap'; 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 { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle'; import type { Lifecycle } from './lifecycle';
@@ -198,6 +198,9 @@ export function attachPanZoom(
app.canvas, app.canvas,
'wheel', 'wheel',
(e: WheelEvent) => { (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(); e.preventDefault();
const oldScale = treeContainer.scale.x; const oldScale = treeContainer.scale.x;
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn; const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
@@ -214,6 +217,28 @@ export function attachPanZoom(
return { flushTooltip }; 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 { interface KeyboardOpts {
onToggleStats: () => void; onToggleStats: () => void;
onCloseModal: () => void; onCloseModal: () => void;
@@ -221,6 +246,11 @@ interface KeyboardOpts {
isStatsVisible: () => boolean; 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`. */ /** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) { export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
const { app, treeContainer } = ctrl; const { app, treeContainer } = ctrl;
@@ -228,6 +258,39 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
const maxZoom = ZOOM.max; const maxZoom = ZOOM.max;
function handleKeydown(e: KeyboardEvent) { 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 // Toggle stats panel with 'C' key
if (e.key === 'c' || e.key === 'C') { if (e.key === 'c' || e.key === 'C') {
if (!opts.isModalVisible()) { if (!opts.isModalVisible()) {
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { buildHierarchy, flattenVisible } from './tree';
import { treeNodes, type TreeNode } from '$lib/data';
const node = (id: string, parentId: string | null): TreeNode =>
({
id,
label: id,
description: '',
x: 0,
y: 0,
parentId,
category: 'skills',
size: 'small'
}) as TreeNode;
describe('buildHierarchy', () => {
it('nests children under their parent', () => {
const roots = buildHierarchy([node('a', null), node('b', 'a'), node('c', 'b')]);
expect(roots).toHaveLength(1);
expect(roots[0].node.id).toBe('a');
expect(roots[0].children[0].node.id).toBe('b');
expect(roots[0].children[0].children[0].node.id).toBe('c');
});
it('treats a node with an unknown parent as a root rather than dropping it', () => {
const roots = buildHierarchy([node('a', null), node('orphan', 'missing')]);
expect(roots.map((r) => r.node.id).sort()).toEqual(['a', 'orphan']);
});
it('covers every real node exactly once', () => {
const seen = new Set<string>();
const walk = (branches: ReturnType<typeof buildHierarchy>) => {
for (const b of branches) {
expect(seen.has(b.node.id)).toBe(false);
seen.add(b.node.id);
walk(b.children);
}
};
walk(buildHierarchy());
expect(seen.size).toBe(treeNodes.length);
});
it('every parentId in the real data resolves to a real node', () => {
const ids = new Set(treeNodes.map((n) => n.id));
const dangling = treeNodes.filter((n) => n.parentId !== null && !ids.has(n.parentId));
expect(dangling.map((n) => n.id)).toEqual([]);
});
});
describe('flattenVisible', () => {
it('only descends into expanded branches, and reports depth', () => {
const roots = buildHierarchy([node('a', null), node('b', 'a'), node('c', 'b')]);
expect(flattenVisible(roots, () => false).map((r) => r.branch.node.id)).toEqual(['a']);
expect(flattenVisible(roots, (id) => id === 'a').map((r) => r.branch.node.id)).toEqual([
'a',
'b'
]);
const all = flattenVisible(roots, () => true);
expect(all.map((r) => r.branch.node.id)).toEqual(['a', 'b', 'c']);
expect(all.map((r) => r.level)).toEqual([1, 2, 3]);
});
});
+50 -1
View File
@@ -26,10 +26,59 @@ export function getDistanceFromOrigin(node: TreeNode): number {
* years of experience (0.3–1.0); other categories use fixed tiers. * years of experience (0.3–1.0); other categories use fixed tiers.
*/ */
export function getBeamIntensity(node: TreeNode): number { export function getBeamIntensity(node: TreeNode): number {
if (node.category === 'skills' && node.yearsOfExperience) { if (node.category === 'skills' && node.yearsOfExperience !== undefined) {
return Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0); return Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0);
} }
if (node.category === 'experience') return 0.7; if (node.category === 'experience') return 0.7;
if (node.category === 'origin') return 1.0; if (node.category === 'origin') return 1.0;
return 0.5; // projects and education return 0.5; // projects and education
} }
/** A node plus its children, for the accessible `role="tree"` navigator. */
export interface TreeBranch {
node: TreeNode;
children: TreeBranch[];
}
/**
* Build the parent→child hierarchy implied by `parentId`.
*
* The canvas draws from the flat array, but an accessible tree widget needs
* real nesting to expose level/expanded state. Cycle-safe: any node whose
* parent chain doesn't terminate at a root is dropped rather than looping.
*/
export function buildHierarchy(nodes: TreeNode[] = treeNodes): TreeBranch[] {
const branches = new Map<string, TreeBranch>();
for (const node of nodes) branches.set(node.id, { node, children: [] });
const roots: TreeBranch[] = [];
for (const branch of branches.values()) {
const parentId = branch.node.parentId;
if (parentId === null) {
roots.push(branch);
continue;
}
const parent = branches.get(parentId);
// Unknown parent → treat as a root rather than silently dropping content.
if (!parent) roots.push(branch);
else parent.children.push(branch);
}
return roots;
}
/** Flatten a hierarchy into the currently-visible rows of a tree widget. */
export function flattenVisible(
branches: TreeBranch[],
isExpanded: (id: string) => boolean,
level = 1
): Array<{ branch: TreeBranch; level: number }> {
const rows: Array<{ branch: TreeBranch; level: number }> = [];
for (const branch of branches) {
rows.push({ branch, level });
if (branch.children.length && isExpanded(branch.node.id)) {
rows.push(...flattenVisible(branch.children, isExpanded, level + 1));
}
}
return rows;
}
+23
View File
@@ -7,6 +7,12 @@
let renderFailed = $state(false); let renderFailed = $state(false);
</script> </script>
<!--
First focusable element on the page: jumps straight to the keyboard tree,
which otherwise sits behind ~20 links in the text version.
-->
<a class="skip-link" href="#tree-nav-label">Skip to skill tree navigator</a>
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). --> <!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
<SeoContent revealed={renderFailed} /> <SeoContent revealed={renderFailed} />
@@ -26,4 +32,21 @@
main.hidden { main.hidden {
display: none; display: none;
} }
.skip-link {
position: absolute;
top: -100%;
left: 0.5rem;
z-index: 2000;
padding: 0.6rem 1rem;
border: 1px solid var(--color-gold);
border-radius: 0 0 8px 8px;
background: var(--panel-from);
color: var(--color-gold);
text-decoration: none;
}
.skip-link:focus-visible {
top: 0;
}
</style> </style>