fix: audit phase 2 — teardown, failure paths, service worker, tooltip, resize

Correctness phase of the frontend audit. Nothing here is visible on a happy
path; it is what the app does when something goes wrong, when the window
changes, or when the component goes away.

Teardown (new src/lib/pixi/lifecycle.ts):
- Nothing was ever released: no app.destroy(), no ticker.remove(), no
  killTweensOf, no onDestroy. ~480 GSAP tweens (most repeat:-1), a ticker
  callback, and a dozen DOM listeners lived forever.
- 112 of those tweens are created inside an onComplete, so they don't exist
  when a call-site sweep would run. gsap.context() handles exactly this:
  GSAP records each animation's context on creation and restores it while
  invoking that animation's callbacks (see _callback in gsap-core), so
  late-created pulses land in the same context. One ctx.kill() gets all of
  them. kill(), not revert(), so GSAP doesn't write properties back onto
  objects about to be destroyed.
- All DOM listeners now go through life.listen(), which registers removal at
  the same time. Previously 6 canvas listeners were never removed, each
  closing over the whole ~713-object scene graph.
- The unmount race: cleanup used to be registered on the LAST line of an
  async onMount, after the asset waterfall, so unmounting mid-load cleaned up
  nothing. The lifecycle is now created before any await, with life.disposed
  guards after each one.
- Hover tweens re-enter the context per event and use overwrite:'auto' so
  rapid sweeps don't stack conflicting tweens.

Failure paths:
- onMount had no try/catch and no WebGL detection. Any init or asset failure
  left the spinner up forever, the stats toggle at opacity:0, and Escape dead
  (attachKeyboard never ran). Now caught, and it hands off to +page.svelte to
  reveal the text version.
- There was no <noscript> anywhere, and SeoContent is clipped to 1x1px with
  html/body overflow:hidden — so JS-disabled visitors got a pure black page.
  A <noscript><style> block now un-hides it with no JS involved.

Service worker (moved static/ -> src/, so SvelteKit builds it):
- It can now use $service-worker, so precache comes from the real build
  manifest instead of a hand-maintained 5-entry list that omitted all JS/CSS,
  and the cache name embeds `version` so old caches actually get pruned.
- cache.addAll was atomic: one 404 failed the whole install silently. Now
  per-asset with a warning on partial failure.
- The catch returned undefined for non-image assets, which makes
  respondWith throw a TypeError — a soft offline failure became a hard one.
  Every path now resolves to a Response.
- Unhashed assets (icons, headshot, resume PDF) were cache-first with no
  expiry, so replacing the resume at the same URL never reached a returning
  visitor. Those are network-first now; only hashed build output is
  cache-first.
- Navigation responses were cached without a status check, so a 404/500 body
  could become the offline fallback.
- Dropped skipWaiting() from install and added a real update prompt +
  controllerchange reload. The SKIP_WAITING handler was previously dead code
  and the "check for updates periodically" comment described an interval that
  did not exist.

Tooltip:
- Wrote Svelte state on every canvas mousemove even with nothing hovered.
  Now gated on hover and coalesced to one write per frame; raw coords are
  still tracked cheaply so the first hover isn't positioned from stale data.
- Animated left/top (layout + paint per frame on an element with two
  color-mix shadows); now translate3d.
- Listener moved to window, so coordinates no longer freeze when a drag
  leaves the canvas.
- Flip logic measured a hardcoded 280x120; now measures the real element
  (actual height is 113, so tall tooltips mis-flipped near the bottom edge).
- Dropped role="tooltip" + aria-live="polite", which announced the full
  label and description on every hover. Nothing referenced it via
  aria-describedby, so the role was inert. Now aria-hidden.

Resize:
- Star positions, gradient radius, vignette size and dust wrap bounds were
  all baked at first paint, so enlarging the window left an unlit band. The
  background is now rebuilt on a rAF-coalesced resize, under its own
  sub-lifecycle. The view is shifted by half the delta rather than re-centred,
  so the user's current view stays anchored.
- isTouchDevice used 'ontouchstart', true on touch-capable laptops and thus
  permanently killing mouse hover. Now matchMedia('(hover: hover)').

Known gap: node radii still don't change when the 768px breakpoint is
crossed mid-session; re-creating all 57 nodes would reset interaction state.

Verified in the production build: resize 1278x1220 -> 1734x821 rebuilds with
zero errors, tooltip positions via transform and stays on screen, fallback
renders as a readable resume page. svelte-check clean, lint clean, 14/14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TehRiehlDealandClaude Opus 5 committed 2026-08-09 00:27:30 -07:00
1 parent 8874b35998
commit 746d81b151
14 files changed
+1127 -692

No files matched your search

+187 -89
View File
@@ -21,8 +21,11 @@
import { createController } from '$lib/pixi/controller';
import { createLines } from '$lib/pixi/lines';
import { createNodes } from '$lib/pixi/nodes';
import { attachPanZoom, attachKeyboard } from '$lib/pixi/interaction';
import { attachPanZoom, attachKeyboard, updateParallax } from '$lib/pixi/interaction';
import { runEntranceAnimations } from '$lib/pixi/animation';
import { createLifecycle } from '$lib/pixi/lifecycle';
const { onRenderFail }: { onRenderFail?: () => void } = $props();
let container: HTMLDivElement;
let selectedNode = $state<TreeNode | null>(null);
@@ -48,7 +51,7 @@
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.
let expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
const expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
function openModal(node: TreeNode) {
selectedNode = node;
@@ -100,118 +103,212 @@
isMobile = windowWidth < MOBILE_BREAKPOINT;
}
const cleanupFunctions: Array<() => void> = [];
// 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();
window.addEventListener('resize', handleResize);
life.listen(window, 'resize', handleResize);
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// 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();
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'
});
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
// eslint-disable-next-line svelte/no-dom-manipulating
container.appendChild(app.canvas);
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'
});
// Background layers (gradient → nebula → stars → dust → vignette)
const bg = createBackground(app, isMobile);
// 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);
// Build the visualisation (connections first, then nodes on top)
controller.lineObjects = createLines(treeContainer);
controller.nodeObjects = await createNodes(treeContainer, {
isMobile,
isTouchDevice,
controller,
onOpen: openModal,
onHover: (node) => {
hoveredNode = node;
// Unmounted while the renderer was initialising — release and stop.
if (life.disposed) {
app.destroy(true, { children: true, texture: true });
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;
// 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 }));
// Pan / zoom / parallax + keyboard navigation
const detachPanZoom = attachPanZoom(controller, {
onTooltipMove: (x, y) => {
tooltipX = x;
tooltipY = y;
// 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);
// 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);
}
});
const detachKeyboard = attachKeyboard(controller, {
onToggleStats: toggleStatsPanel,
onCloseModal: closeModal,
isModalVisible: () => modalVisible,
isStatsVisible: () => statsPanelVisible
});
// Sequenced entrance animation
runEntranceAnimations(controller, {
isMobile,
onTitleDone: () => {
showWelcomeTitle = false;
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();
}
});
// Store cleanup functions
cleanupFunctions.push(() => {
window.removeEventListener('resize', handleResize);
detachPanZoom();
detachKeyboard();
});
// 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 () => {
cleanupFunctions.forEach((fn) => fn());
};
return () => life.dispose();
});
// Calculate tooltip position to keep it on screen
// 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(() => {
let x = tooltipX + 15;
let y = tooltipY + 15;
const gap = 15;
const rect = tooltipEl?.getBoundingClientRect();
const width = rect?.width || 280;
const height = rect?.height || 120;
// Keep tooltip on screen
const tooltipWidth = 280;
const tooltipHeight = 120;
let x = tooltipX + gap;
let y = tooltipY + gap;
if (x + tooltipWidth > windowWidth) {
x = tooltipX - tooltipWidth - 15;
}
if (y + tooltipHeight > windowHeight) {
y = tooltipY - tooltipHeight - 15;
}
// 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 `left: ${x}px; top: ${y}px;`;
return `transform: translate3d(${x}px, ${y}px, 0);`;
});
</script>
@@ -239,6 +336,7 @@
<!-- Hover Tooltip (hidden on mobile) -->
{#if hoveredNode && !modalVisible && !isMobile}
<Tooltip
bind:element={tooltipEl}
node={hoveredNode}
style={tooltipStyle}
colorHex={categoryColorHex[hoveredNode.category]}