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>
406 lines
13 KiB
Svelte
406 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
import { Application, Container } from 'pixi.js';
|
|
import type { TreeNode } from '$lib/data';
|
|
import { categoryColorHex } from '$lib/theme';
|
|
import LoadingIndicator from './LoadingIndicator.svelte';
|
|
import WelcomeTitle from './WelcomeTitle.svelte';
|
|
import Tooltip from './Tooltip.svelte';
|
|
import NodeModal from './NodeModal.svelte';
|
|
import StatsPanel from './StatsPanel.svelte';
|
|
import TreeNavigator from './TreeNavigator.svelte';
|
|
import { MOBILE_BREAKPOINT } from '$lib/config';
|
|
import {
|
|
BRANCH_IDS,
|
|
calculatePlayerLevel,
|
|
getPlayerClass,
|
|
aggregateSkills,
|
|
countByProficiency
|
|
} from '$lib/utils/stats';
|
|
import { createBackground } from '$lib/pixi/background';
|
|
import { createController, type SkillTreeController } from '$lib/pixi/controller';
|
|
import { createLines } from '$lib/pixi/lines';
|
|
import { createNodes } from '$lib/pixi/nodes';
|
|
import { attachPanZoom, attachKeyboard, updateParallax, panToNode } from '$lib/pixi/interaction';
|
|
import { runEntranceAnimations } from '$lib/pixi/animation';
|
|
import { createLifecycle } from '$lib/pixi/lifecycle';
|
|
|
|
const { onRenderFail }: { onRenderFail?: () => void } = $props();
|
|
|
|
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 modalVisible = $state(false);
|
|
let showWelcomeTitle = $state(true);
|
|
|
|
// Tooltip state
|
|
let hoveredNode = $state<TreeNode | null>(null);
|
|
let tooltipX = $state(0);
|
|
let tooltipY = $state(0);
|
|
|
|
// Screen size detection
|
|
let isMobile = $state(false);
|
|
let windowWidth = $state(0);
|
|
let windowHeight = $state(0);
|
|
|
|
let modalOpenedAt = 0;
|
|
|
|
// Loading state for Lighthouse LCP detection
|
|
let isLoading = $state(true);
|
|
|
|
// Stats panel state
|
|
let statsPanelVisible = $state(false);
|
|
// SvelteSet, not $state(new Set()) — Svelte 5 does not proxy a plain Set, so
|
|
// .add()/.delete() would be untracked and the panel would never re-render.
|
|
const expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
|
|
|
|
function openModal(node: TreeNode) {
|
|
selectedNode = node;
|
|
modalVisible = true;
|
|
modalOpenedAt = Date.now();
|
|
// Hide tooltip when modal opens
|
|
hoveredNode = null;
|
|
}
|
|
|
|
function closeModal() {
|
|
modalVisible = false;
|
|
setTimeout(() => {
|
|
selectedNode = null;
|
|
}, 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() {
|
|
statsPanelVisible = !statsPanelVisible;
|
|
if (statsPanelVisible && modalVisible) {
|
|
closeModal(); // Close modal when opening stats panel
|
|
}
|
|
}
|
|
|
|
function toggleBranch(branchId: string) {
|
|
if (expandedBranches.has(branchId)) {
|
|
expandedBranches.delete(branchId);
|
|
} else {
|
|
expandedBranches.add(branchId);
|
|
}
|
|
}
|
|
|
|
// Stats calculations (reactive)
|
|
const playerLevel = $derived(calculatePlayerLevel());
|
|
const playerClass = $derived(getPlayerClass());
|
|
const skillsByBranch = $derived(aggregateSkills());
|
|
const skillCounts = $derived(countByProficiency());
|
|
const totalSkills = $derived(Object.values(skillCounts).reduce((sum, count) => sum + count, 0));
|
|
|
|
// Update screen size
|
|
function handleResize() {
|
|
windowWidth = window.innerWidth;
|
|
windowHeight = window.innerHeight;
|
|
isMobile = windowWidth < MOBILE_BREAKPOINT;
|
|
}
|
|
|
|
// One owner for every Pixi/GSAP/DOM resource this component allocates.
|
|
// Registered with the component's teardown BEFORE any await, so unmounting
|
|
// mid-load still releases everything (this used to be the last statement of
|
|
// the async onMount, so an early unmount cleaned up nothing at all).
|
|
const life = createLifecycle();
|
|
|
|
onMount(async () => {
|
|
handleResize();
|
|
life.listen(window, 'resize', handleResize);
|
|
|
|
// matchMedia('(hover: hover)') rather than 'ontouchstart': the latter is
|
|
// true on touch-capable laptops, which permanently killed mouse hover.
|
|
const isTouchDevice = !window.matchMedia('(hover: hover)').matches;
|
|
|
|
const app = new Application();
|
|
|
|
try {
|
|
await app.init({
|
|
background: '#05050a',
|
|
resizeTo: container,
|
|
// The scene is soft glow circles — MSAA costs fill rate and buys almost
|
|
// nothing here. Sharpness comes from rendering at device resolution instead.
|
|
antialias: false,
|
|
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
|
autoDensity: true,
|
|
powerPreference: 'high-performance'
|
|
});
|
|
|
|
// Unmounted while the renderer was initialising — release and stop.
|
|
if (life.disposed) {
|
|
app.destroy(true, { children: true, texture: true });
|
|
return;
|
|
}
|
|
|
|
// Tearing down the renderer has to happen last, so register it first
|
|
// (dispose runs newest-first).
|
|
life.add(() => app.destroy(true, { children: true, texture: true }));
|
|
|
|
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
|
container.appendChild(app.canvas);
|
|
|
|
// Background layers (gradient → nebula → stars → dust → vignette).
|
|
// Its own sub-lifecycle so a resize rebuild can kill just its ~315
|
|
// ambient tweens and the dust ticker, without touching the tree's.
|
|
let bgLife = createLifecycle();
|
|
let bg = createBackground(app, isMobile, bgLife);
|
|
life.add(() => bgLife.dispose());
|
|
|
|
// Tree container — pan/zoom transform is applied here
|
|
const treeContainer = new Container();
|
|
app.stage.addChild(treeContainer);
|
|
treeContainer.x = app.screen.width / 2;
|
|
treeContainer.y = app.screen.height / 2;
|
|
|
|
// Start zoomed in; the entrance animation zooms out to endScale.
|
|
const startScale = isMobile ? 3 : 4;
|
|
const endScale = isMobile ? 0.6 : 1;
|
|
treeContainer.scale.set(startScale);
|
|
treeContainer.alpha = 1;
|
|
|
|
const controller = createController(app, treeContainer, bg, endScale);
|
|
controllerRef = controller;
|
|
life.add(() => (controllerRef = null));
|
|
|
|
// Build the visualisation (connections first, then nodes on top)
|
|
controller.lineObjects = createLines(treeContainer);
|
|
controller.nodeObjects = await createNodes(treeContainer, {
|
|
isMobile,
|
|
isTouchDevice,
|
|
controller,
|
|
life,
|
|
onOpen: openModal,
|
|
onHover: (node) => {
|
|
hoveredNode = node;
|
|
// Seed the tooltip at the current pointer before it first paints.
|
|
if (node) panZoom?.flushTooltip();
|
|
}
|
|
});
|
|
|
|
// Unmounted while textures were loading.
|
|
if (life.disposed) return;
|
|
|
|
// Textures are loaded and the scene graph is built — only now is the canvas
|
|
// genuinely ready. (This used to fire right after app.init(), so the spinner
|
|
// resolved onto an empty screen.)
|
|
isLoading = false;
|
|
|
|
// Pan / zoom / parallax + keyboard navigation
|
|
const panZoom = attachPanZoom(
|
|
controller,
|
|
{
|
|
onTooltipMove: (x, y) => {
|
|
tooltipX = x;
|
|
tooltipY = y;
|
|
},
|
|
hasHover: () => hoveredNode !== null
|
|
},
|
|
life
|
|
);
|
|
attachKeyboard(
|
|
controller,
|
|
{
|
|
onToggleStats: toggleStatsPanel,
|
|
onCloseModal: closeModal,
|
|
isModalVisible: () => modalVisible,
|
|
isStatsVisible: () => statsPanelVisible
|
|
},
|
|
life
|
|
);
|
|
|
|
// Every star position, the gradient radius, the vignette size and the
|
|
// dust wrap bounds are baked from app.screen at build time. Without this
|
|
// a resized window leaves an unlit, starless band.
|
|
let lastWidth = app.screen.width;
|
|
let lastHeight = app.screen.height;
|
|
let resizeFrame = -1;
|
|
|
|
function rebuildBackground() {
|
|
bgLife.dispose();
|
|
bg.deepBackgroundContainer.destroy({ children: true });
|
|
bg.nebulaContainer.destroy({ children: true });
|
|
bg.starContainer.destroy({ children: true });
|
|
bg.vignette.destroy();
|
|
|
|
bgLife = createLifecycle();
|
|
bg = createBackground(app, isMobile, bgLife);
|
|
controller.bg = bg;
|
|
|
|
// createBackground appends to the stage, so the tree would end up
|
|
// behind the new layers. Restore it to the top.
|
|
app.stage.setChildIndex(treeContainer, app.stage.children.length - 1);
|
|
updateParallax(controller);
|
|
}
|
|
|
|
function handleCanvasResize() {
|
|
resizeFrame = -1;
|
|
const width = app.screen.width;
|
|
const height = app.screen.height;
|
|
if (width === lastWidth && height === lastHeight) return;
|
|
|
|
// Keep whatever the user was looking at anchored: shift by half the
|
|
// delta rather than snapping back to centre and yanking their view.
|
|
const dx = (width - lastWidth) / 2;
|
|
const dy = (height - lastHeight) / 2;
|
|
treeContainer.x += dx;
|
|
treeContainer.y += dy;
|
|
controller.initialTreeX += dx;
|
|
controller.initialTreeY += dy;
|
|
|
|
lastWidth = width;
|
|
lastHeight = height;
|
|
rebuildBackground();
|
|
}
|
|
|
|
// Coalesce to one rebuild per frame — resize fires continuously while
|
|
// dragging a window edge, and a rebuild allocates ~300 Graphics.
|
|
life.listen(window, 'resize', () => {
|
|
if (resizeFrame === -1) resizeFrame = requestAnimationFrame(handleCanvasResize);
|
|
});
|
|
life.add(() => {
|
|
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
|
|
});
|
|
|
|
// Sequenced entrance animation
|
|
runEntranceAnimations(controller, {
|
|
isMobile,
|
|
life,
|
|
onTitleDone: () => {
|
|
showWelcomeTitle = false;
|
|
}
|
|
});
|
|
} catch (err) {
|
|
// No WebGL, or a texture failed to load. Don't strand the user on a
|
|
// spinner that never resolves — reveal the text version instead.
|
|
console.error('Skill tree failed to initialise:', err);
|
|
isLoading = false;
|
|
// Hands off to +page.svelte, which reveals the text version.
|
|
onRenderFail?.();
|
|
life.dispose();
|
|
app.destroy(true, { children: true, texture: true });
|
|
}
|
|
});
|
|
|
|
// Cleanup on component unmount
|
|
$effect(() => {
|
|
return () => life.dispose();
|
|
});
|
|
|
|
// Live tooltip element, so flip decisions use its real measured size rather
|
|
// than a hardcoded 280x120 guess (descriptions vary in length, so the old
|
|
// height constant mis-flipped tall tooltips near the viewport bottom).
|
|
let tooltipEl = $state<HTMLDivElement | null>(null);
|
|
|
|
const tooltipStyle = $derived.by(() => {
|
|
const gap = 15;
|
|
const rect = tooltipEl?.getBoundingClientRect();
|
|
const width = rect?.width || 280;
|
|
const height = rect?.height || 120;
|
|
|
|
let x = tooltipX + gap;
|
|
let y = tooltipY + gap;
|
|
|
|
// Flip to the other side of the cursor rather than overflow the viewport.
|
|
if (x + width > windowWidth) x = Math.max(0, tooltipX - width - gap);
|
|
if (y + height > windowHeight) y = Math.max(0, tooltipY - height - gap);
|
|
|
|
return `transform: translate3d(${x}px, ${y}px, 0);`;
|
|
});
|
|
</script>
|
|
|
|
<svelte:window bind:innerWidth={windowWidth} bind:innerHeight={windowHeight} />
|
|
|
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
<div
|
|
bind:this={container}
|
|
class="canvas-container"
|
|
role="img"
|
|
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"
|
|
></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 -->
|
|
{#if isLoading}
|
|
<LoadingIndicator />
|
|
{/if}
|
|
|
|
<!-- Welcome Title Card -->
|
|
{#if showWelcomeTitle}
|
|
<WelcomeTitle />
|
|
{/if}
|
|
|
|
<!-- Hover Tooltip (hidden on mobile) -->
|
|
{#if hoveredNode && !modalVisible && !isMobile}
|
|
<Tooltip
|
|
bind:element={tooltipEl}
|
|
node={hoveredNode}
|
|
style={tooltipStyle}
|
|
colorHex={categoryColorHex[hoveredNode.category]}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Modal Overlay -->
|
|
{#if selectedNode}
|
|
<NodeModal
|
|
node={selectedNode}
|
|
visible={modalVisible}
|
|
onClose={closeModal}
|
|
onDismiss={dismissModal}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Character Stats Panel (toggle button + backdrop + panel) -->
|
|
<StatsPanel
|
|
visible={statsPanelVisible}
|
|
{playerLevel}
|
|
{playerClass}
|
|
{skillCounts}
|
|
{totalSkills}
|
|
{skillsByBranch}
|
|
{expandedBranches}
|
|
onToggle={toggleStatsPanel}
|
|
onToggleBranch={toggleBranch}
|
|
/>
|
|
|
|
<style>
|
|
/* Focus indicators are global now — see app.css. A scoped rule here could
|
|
never reach NodeModal or StatsPanel children. */
|
|
.canvas-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
touch-action: none;
|
|
}
|
|
|
|
.canvas-container:focus-visible {
|
|
outline: 3px solid var(--color-gold);
|
|
outline-offset: -3px;
|
|
}
|
|
</style>
|