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>
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
// Tree node rendering: the origin profile node and regular icon nodes.
|
|
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
|
// enables them; see animation.ts.
|
|
import { Graphics, Container, Assets, Sprite, type Texture } from 'pixi.js';
|
|
import { gsap } from 'gsap';
|
|
import { treeNodes, type TreeNode } from '$lib/data';
|
|
import { categoryColors } from '$lib/theme';
|
|
import { getNodeRadius } from '$lib/utils/tree';
|
|
import type { NodeObject } from './types';
|
|
import type { SkillTreeController } from './controller';
|
|
import type { Lifecycle } from './lifecycle';
|
|
|
|
interface NodeOpts {
|
|
isMobile: boolean;
|
|
isTouchDevice: boolean;
|
|
controller: SkillTreeController;
|
|
life: Lifecycle;
|
|
onOpen: (node: TreeNode) => void;
|
|
onHover: (node: TreeNode | null) => void;
|
|
}
|
|
|
|
/** Shared pointer wiring for both the origin and regular nodes. */
|
|
function attachNodeInteraction(
|
|
target: Container,
|
|
glowGraphics: Graphics,
|
|
node: TreeNode,
|
|
opts: NodeOpts
|
|
) {
|
|
target.on('pointerdown', () => {
|
|
opts.controller.hasMoved = false;
|
|
});
|
|
|
|
target.on('pointerup', () => {
|
|
if (!opts.controller.hasMoved) {
|
|
opts.onOpen(node);
|
|
}
|
|
});
|
|
|
|
// Hover tweens are created per-event, long after setup, so each creation has
|
|
// to re-enter the GSAP context to stay killable. `overwrite: 'auto'` stops
|
|
// rapid in/out sweeps from stacking conflicting tweens on the same property.
|
|
target.on('pointerover', () => {
|
|
opts.life.animate(() => {
|
|
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2, overwrite: 'auto' });
|
|
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2, overwrite: 'auto' });
|
|
});
|
|
if (!opts.isTouchDevice) opts.onHover(node);
|
|
});
|
|
|
|
target.on('pointerout', () => {
|
|
opts.life.animate(() => {
|
|
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2, overwrite: 'auto' });
|
|
gsap.to(glowGraphics, { alpha: 1, duration: 0.2, overwrite: 'auto' });
|
|
});
|
|
if (!opts.isTouchDevice) opts.onHover(null);
|
|
});
|
|
}
|
|
|
|
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
|
|
const { isMobile } = opts;
|
|
const nodeObjects: NodeObject[] = [];
|
|
|
|
// Load every texture in ONE parallel batch. Previously each icon was awaited
|
|
// inside the loop below, which serialised ~56 round-trips before the tree
|
|
// could render or accept input.
|
|
const iconUrls = [...new Set(treeNodes.map((n) => n.icon).filter((s): s is string => !!s))];
|
|
|
|
const [profileTexture, iconTextures] = await Promise.all([
|
|
Assets.load<Texture>('/headshot.jpg'),
|
|
// Array form resolves to a url -> Texture map and fetches concurrently.
|
|
iconUrls.length
|
|
? Assets.load<Texture>(iconUrls)
|
|
: Promise.resolve({} as Record<string, Texture>)
|
|
]);
|
|
|
|
for (const node of treeNodes) {
|
|
const radius = getNodeRadius(node.size, isMobile);
|
|
const color = categoryColors[node.category];
|
|
|
|
const glowGraphics = new Graphics();
|
|
glowGraphics.circle(0, 0, radius + 8).fill({ color, alpha: 0.1 });
|
|
glowGraphics.circle(0, 0, radius + 4).fill({ color, alpha: 0.15 });
|
|
glowGraphics.x = node.x;
|
|
glowGraphics.y = node.y;
|
|
glowGraphics.alpha = 0;
|
|
treeContainer.addChild(glowGraphics);
|
|
|
|
if (node.id === 'origin') {
|
|
const profileContainer = new Container();
|
|
profileContainer.x = node.x;
|
|
profileContainer.y = node.y;
|
|
|
|
const maskGraphics = new Graphics();
|
|
maskGraphics.circle(0, 0, radius).fill({ color: 0xffffff });
|
|
profileContainer.addChild(maskGraphics);
|
|
|
|
const profileSprite = new Sprite(profileTexture);
|
|
const scale = (radius * 2) / Math.min(profileTexture.width, profileTexture.height);
|
|
profileSprite.scale.set(scale);
|
|
profileSprite.anchor.set(0.5);
|
|
profileSprite.mask = maskGraphics;
|
|
profileContainer.addChild(profileSprite);
|
|
|
|
const borderGraphics = new Graphics();
|
|
borderGraphics.circle(0, 0, radius + 2).stroke({ width: 3, color });
|
|
borderGraphics.circle(0, 0, radius + 6).stroke({ width: 2, color, alpha: 0.5 });
|
|
profileContainer.addChild(borderGraphics);
|
|
|
|
profileContainer.alpha = 0;
|
|
profileContainer.scale.set(0.5);
|
|
|
|
// Disable interactivity until animation completes
|
|
profileContainer.eventMode = 'none';
|
|
profileContainer.cursor = 'pointer';
|
|
|
|
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
|
|
|
treeContainer.addChild(profileContainer);
|
|
nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
|
|
} else {
|
|
const nodeContainer = new Container();
|
|
nodeContainer.x = node.x;
|
|
nodeContainer.y = node.y;
|
|
|
|
const nodeGraphics = new Graphics();
|
|
nodeGraphics.circle(0, 0, radius).fill({ color: 0x12121a }).stroke({ width: 2, color });
|
|
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
|
|
nodeContainer.addChild(nodeGraphics);
|
|
|
|
const iconTexture = node.icon ? iconTextures[node.icon] : undefined;
|
|
if (node.icon && !iconTexture) {
|
|
console.warn(`Could not load icon for ${node.id}:`, node.icon);
|
|
}
|
|
if (iconTexture) {
|
|
const iconSprite = new Sprite(iconTexture);
|
|
|
|
const iconSize = radius * 1.4;
|
|
const scale = iconSize / Math.max(iconTexture.width, iconTexture.height);
|
|
iconSprite.scale.set(scale);
|
|
iconSprite.anchor.set(0.5);
|
|
iconSprite.tint = color;
|
|
|
|
nodeContainer.addChild(iconSprite);
|
|
}
|
|
|
|
nodeContainer.alpha = 0;
|
|
nodeContainer.scale.set(0.5);
|
|
|
|
// Disable interactivity until animation completes
|
|
nodeContainer.eventMode = 'none';
|
|
nodeContainer.cursor = 'pointer';
|
|
|
|
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
|
|
|
treeContainer.addChild(nodeContainer);
|
|
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
|
|
}
|
|
}
|
|
|
|
return nodeObjects;
|
|
}
|