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
+195
-186
@@ -6,9 +6,11 @@ import { gsap } from 'gsap';
|
||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||
import { updateParallax } from './interaction';
|
||||
import type { SkillTreeController } from './controller';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
interface EntranceOpts {
|
||||
isMobile: boolean;
|
||||
life: Lifecycle;
|
||||
/** Called when the welcome title should be removed (matches its CSS fade). */
|
||||
onTitleDone: () => void;
|
||||
}
|
||||
@@ -22,201 +24,208 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
|
||||
const zoomDuration = isMobile ? 2.5 : 4.5;
|
||||
const nodeStagger = isMobile ? 0.05 : 0.08;
|
||||
|
||||
// Hide all lines and plasma beams initially
|
||||
lineObjects.forEach((line) => {
|
||||
line.graphics.alpha = 0;
|
||||
line.glowGraphics.alpha = 0;
|
||||
line.plasmaBeam.alpha = 0;
|
||||
});
|
||||
|
||||
// Hide all nodes initially
|
||||
nodeObjects.forEach((obj) => {
|
||||
obj.graphics.alpha = 0;
|
||||
obj.graphics.scale.set(0.5);
|
||||
obj.glowGraphics.alpha = 0;
|
||||
});
|
||||
|
||||
// Sort by distance from origin
|
||||
const sortedNodes = [...nodeObjects].sort(
|
||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||
);
|
||||
const sortedLines = [...lineObjects].sort(
|
||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||
);
|
||||
|
||||
// STEP 1: After title fades, show origin node first (overlap on mobile)
|
||||
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
|
||||
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
|
||||
const originDuration = isMobile ? 0.3 : 0.6;
|
||||
|
||||
if (originNode && isMobile) {
|
||||
originNode.graphics.scale.set(0.8); // Start slightly larger
|
||||
}
|
||||
|
||||
if (originNode) {
|
||||
gsap.to(originNode.graphics, {
|
||||
alpha: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'power2.out'
|
||||
// The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses,
|
||||
// some created inside an onComplete. GSAP restores an animation's context
|
||||
// while running its callbacks, so wrapping the whole sequence here makes
|
||||
// even those late-created pulses killable via the lifecycle.
|
||||
opts.life.animate(() => {
|
||||
// Hide all lines and plasma beams initially
|
||||
lineObjects.forEach((line) => {
|
||||
line.graphics.alpha = 0;
|
||||
line.glowGraphics.alpha = 0;
|
||||
line.plasmaBeam.alpha = 0;
|
||||
});
|
||||
gsap.to(originNode.graphics.scale, {
|
||||
x: 1,
|
||||
y: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'back.out(1.7)',
|
||||
onComplete: () => {
|
||||
if (originNode.graphics instanceof Container) {
|
||||
originNode.graphics.eventMode = 'static';
|
||||
|
||||
// Hide all nodes initially
|
||||
nodeObjects.forEach((obj) => {
|
||||
obj.graphics.alpha = 0;
|
||||
obj.graphics.scale.set(0.5);
|
||||
obj.glowGraphics.alpha = 0;
|
||||
});
|
||||
|
||||
// Sort by distance from origin
|
||||
const sortedNodes = [...nodeObjects].sort(
|
||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||
);
|
||||
const sortedLines = [...lineObjects].sort(
|
||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||
);
|
||||
|
||||
// STEP 1: After title fades, show origin node first (overlap on mobile)
|
||||
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
|
||||
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
|
||||
const originDuration = isMobile ? 0.3 : 0.6;
|
||||
|
||||
if (originNode && isMobile) {
|
||||
originNode.graphics.scale.set(0.8); // Start slightly larger
|
||||
}
|
||||
|
||||
if (originNode) {
|
||||
gsap.to(originNode.graphics, {
|
||||
alpha: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.to(originNode.graphics.scale, {
|
||||
x: 1,
|
||||
y: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'back.out(1.7)',
|
||||
onComplete: () => {
|
||||
if (originNode.graphics instanceof Container) {
|
||||
originNode.graphics.eventMode = 'static';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
gsap.to(originNode.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 2: Start zoom out after origin appears
|
||||
const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3;
|
||||
|
||||
gsap.to(treeContainer.scale, {
|
||||
x: endScale,
|
||||
y: endScale,
|
||||
duration: zoomDuration,
|
||||
delay: zoomDelay,
|
||||
ease: 'power1.out',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
});
|
||||
gsap.to(originNode.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 2: Start zoom out after origin appears
|
||||
const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3;
|
||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||
const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
|
||||
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
|
||||
|
||||
gsap.to(treeContainer.scale, {
|
||||
x: endScale,
|
||||
y: endScale,
|
||||
duration: zoomDuration,
|
||||
delay: zoomDelay,
|
||||
ease: 'power1.out',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
});
|
||||
otherNodes.forEach((obj, index) => {
|
||||
const delay = nodeStartDelay + index * nodeStagger;
|
||||
|
||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||
const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
|
||||
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
|
||||
|
||||
otherNodes.forEach((obj, index) => {
|
||||
const delay = nodeStartDelay + index * nodeStagger;
|
||||
|
||||
gsap.to(obj.graphics, {
|
||||
alpha: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.to(obj.graphics.scale, {
|
||||
x: 1,
|
||||
y: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'back.out(1.7)',
|
||||
onComplete: () => {
|
||||
if (obj.graphics instanceof Container) {
|
||||
obj.graphics.eventMode = 'static';
|
||||
gsap.to(obj.graphics, {
|
||||
alpha: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.to(obj.graphics.scale, {
|
||||
x: 1,
|
||||
y: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'back.out(1.7)',
|
||||
onComplete: () => {
|
||||
if (obj.graphics instanceof Container) {
|
||||
obj.graphics.eventMode = 'static';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
gsap.to(obj.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.to(obj.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Start ambient pulse after node appears
|
||||
gsap.to(obj.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
y: 1.4,
|
||||
duration: 2 + Math.random() * 1,
|
||||
delay: delay + 0.5,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
});
|
||||
|
||||
// Start ambient pulse after node appears
|
||||
gsap.to(obj.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
y: 1.4,
|
||||
duration: 2 + Math.random() * 1,
|
||||
delay: delay + 0.5,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
// Start origin pulse too
|
||||
if (originNode) {
|
||||
gsap.to(originNode.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
y: 1.4,
|
||||
duration: 2 + Math.random() * 1,
|
||||
delay: titleDuration + 1,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 4: Animate lines and plasma beams as nodes appear
|
||||
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
|
||||
|
||||
sortedLines.forEach((line, index) => {
|
||||
const delay = lineStartDelay + index * nodeStagger;
|
||||
|
||||
// Core line appears first
|
||||
gsap.to(line.graphics, {
|
||||
alpha: 1,
|
||||
duration: 0.4,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Base glow appears with line
|
||||
gsap.to(line.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: 0.6,
|
||||
delay: delay + 0.1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Plasma beam fades in, then breathes continuously
|
||||
gsap.to(line.plasmaBeam, {
|
||||
alpha: 1,
|
||||
duration: 0.8,
|
||||
delay: delay + 0.2,
|
||||
ease: 'power2.out',
|
||||
onComplete: () => {
|
||||
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
|
||||
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
|
||||
|
||||
gsap.to(line.plasmaBeam, {
|
||||
alpha: minAlpha,
|
||||
duration: pulseDuration,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
|
||||
const glowMinAlpha = 0.12 * line.intensity;
|
||||
gsap.to(line.glowGraphics, {
|
||||
alpha: glowMinAlpha,
|
||||
duration: pulseDuration * 1.2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up title card after animation (matches titleDuration)
|
||||
const titleTimer = setTimeout(
|
||||
() => {
|
||||
opts.onTitleDone();
|
||||
},
|
||||
isMobile ? 1500 : 2500
|
||||
);
|
||||
opts.life.add(() => clearTimeout(titleTimer));
|
||||
|
||||
// Fade in stats panel toggle button with the tree
|
||||
const toggleButton = document.querySelector('.stats-panel-toggle');
|
||||
if (toggleButton) {
|
||||
gsap.to(toggleButton, {
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start origin pulse too
|
||||
if (originNode) {
|
||||
gsap.to(originNode.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
y: 1.4,
|
||||
duration: 2 + Math.random() * 1,
|
||||
delay: titleDuration + 1,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 4: Animate lines and plasma beams as nodes appear
|
||||
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
|
||||
|
||||
sortedLines.forEach((line, index) => {
|
||||
const delay = lineStartDelay + index * nodeStagger;
|
||||
|
||||
// Core line appears first
|
||||
gsap.to(line.graphics, {
|
||||
alpha: 1,
|
||||
duration: 0.4,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Base glow appears with line
|
||||
gsap.to(line.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: 0.6,
|
||||
delay: delay + 0.1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Plasma beam fades in, then breathes continuously
|
||||
gsap.to(line.plasmaBeam, {
|
||||
alpha: 1,
|
||||
duration: 0.8,
|
||||
delay: delay + 0.2,
|
||||
ease: 'power2.out',
|
||||
onComplete: () => {
|
||||
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
|
||||
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
|
||||
|
||||
gsap.to(line.plasmaBeam, {
|
||||
alpha: minAlpha,
|
||||
duration: pulseDuration,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
|
||||
const glowMinAlpha = 0.12 * line.intensity;
|
||||
gsap.to(line.glowGraphics, {
|
||||
alpha: glowMinAlpha,
|
||||
duration: pulseDuration * 1.2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up title card after animation (matches titleDuration)
|
||||
setTimeout(
|
||||
() => {
|
||||
opts.onTitleDone();
|
||||
},
|
||||
isMobile ? 1500 : 2500
|
||||
);
|
||||
|
||||
// Fade in stats panel toggle button with the tree
|
||||
const toggleButton = document.querySelector('.stats-panel-toggle');
|
||||
if (toggleButton) {
|
||||
gsap.to(toggleButton, {
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user