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:
1 parent
8874b35998
commit
746d81b151
14 files changed
+1127
-692
No files matched your search
@@ -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]}
|
||||
|
||||
Reference in new issue
Block a user