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
+637 -202

No files matched your search

+40
View File
@@ -47,3 +47,43 @@ body {
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
/*
* Un-hides the text version when the canvas can't run — no JS, or no WebGL.
* Applied by <noscript> (see +layout.svelte) and by the render-failure path in
* SkillTree.svelte. Also restores scrolling, which is disabled globally above
* for the canvas view.
*/
.sr-only.revealed {
position: static;
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
max-width: 60rem;
padding: 2rem 1.5rem;
color: #e8e8f0;
line-height: 1.6;
}
html:has(.sr-only.revealed),
html:has(.sr-only.revealed) body {
overflow: auto;
}
.sr-only.revealed a {
color: var(--color-gold);
}
.sr-only.revealed h1,
.sr-only.revealed h2,
.sr-only.revealed h3 {
color: var(--color-gold);
margin: 1.25rem 0 0.5rem;
}
.sr-only.revealed ul {
padding-left: 1.25rem;
}
+5 -1
View File
@@ -14,6 +14,10 @@
} from '$lib/data'; } from '$lib/data';
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats'; import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
// Set when the canvas can't render, so this becomes the visible page rather
// than a screen-reader-only mirror of it.
const { revealed = false }: { revealed?: boolean } = $props();
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin'); const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
// Leaf nodes only — container/branch nodes lack these category fields. // Leaf nodes only — container/branch nodes lack these category fields.
@@ -34,7 +38,7 @@
</script> </script>
{#if origin} {#if origin}
<section class="sr-only" aria-label="Portfolio (text version)"> <section class="sr-only" class:revealed aria-label="Portfolio (text version)">
<h1>{origin.fullName} — {origin.jobTitle}</h1> <h1>{origin.fullName} — {origin.jobTitle}</h1>
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if} {#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
{#if origin.location}<p>Location: {origin.location}</p>{/if} {#if origin.location}<p>Location: {origin.location}</p>{/if}
+132 -34
View File
@@ -21,8 +21,11 @@
import { createController } from '$lib/pixi/controller'; import { createController } from '$lib/pixi/controller';
import { createLines } from '$lib/pixi/lines'; import { createLines } from '$lib/pixi/lines';
import { createNodes } from '$lib/pixi/nodes'; 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 { runEntranceAnimations } from '$lib/pixi/animation';
import { createLifecycle } from '$lib/pixi/lifecycle';
const { onRenderFail }: { onRenderFail?: () => void } = $props();
let container: HTMLDivElement; let container: HTMLDivElement;
let selectedNode = $state<TreeNode | null>(null); let selectedNode = $state<TreeNode | null>(null);
@@ -48,7 +51,7 @@
let statsPanelVisible = $state(false); let statsPanelVisible = $state(false);
// SvelteSet, not $state(new Set()) — Svelte 5 does not proxy a plain Set, so // 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. // .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) { function openModal(node: TreeNode) {
selectedNode = node; selectedNode = node;
@@ -100,15 +103,23 @@
isMobile = windowWidth < MOBILE_BREAKPOINT; 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 () => { onMount(async () => {
handleResize(); 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(); const app = new Application();
try {
await app.init({ await app.init({
background: '#05050a', background: '#05050a',
resizeTo: container, resizeTo: container,
@@ -120,12 +131,26 @@
powerPreference: 'high-performance' 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. // PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
// eslint-disable-next-line svelte/no-dom-manipulating // eslint-disable-next-line svelte/no-dom-manipulating
container.appendChild(app.canvas); container.appendChild(app.canvas);
// Background layers (gradient → nebula → stars → dust → vignette) // Background layers (gradient → nebula → stars → dust → vignette).
const bg = createBackground(app, isMobile); // 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 // Tree container — pan/zoom transform is applied here
const treeContainer = new Container(); const treeContainer = new Container();
@@ -147,71 +172,143 @@
isMobile, isMobile,
isTouchDevice, isTouchDevice,
controller, controller,
life,
onOpen: openModal, onOpen: openModal,
onHover: (node) => { onHover: (node) => {
hoveredNode = 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 // 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 // genuinely ready. (This used to fire right after app.init(), so the spinner
// resolved onto an empty screen.) // resolved onto an empty screen.)
isLoading = false; isLoading = false;
// Pan / zoom / parallax + keyboard navigation // Pan / zoom / parallax + keyboard navigation
const detachPanZoom = attachPanZoom(controller, { const panZoom = attachPanZoom(
controller,
{
onTooltipMove: (x, y) => { onTooltipMove: (x, y) => {
tooltipX = x; tooltipX = x;
tooltipY = y; tooltipY = y;
} },
}); hasHover: () => hoveredNode !== null
const detachKeyboard = attachKeyboard(controller, { },
life
);
attachKeyboard(
controller,
{
onToggleStats: toggleStatsPanel, onToggleStats: toggleStatsPanel,
onCloseModal: closeModal, onCloseModal: closeModal,
isModalVisible: () => modalVisible, isModalVisible: () => modalVisible,
isStatsVisible: () => statsPanelVisible 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 // Sequenced entrance animation
runEntranceAnimations(controller, { runEntranceAnimations(controller, {
isMobile, isMobile,
life,
onTitleDone: () => { onTitleDone: () => {
showWelcomeTitle = false; showWelcomeTitle = false;
} }
}); });
} catch (err) {
// Store cleanup functions // No WebGL, or a texture failed to load. Don't strand the user on a
cleanupFunctions.push(() => { // spinner that never resolves — reveal the text version instead.
window.removeEventListener('resize', handleResize); console.error('Skill tree failed to initialise:', err);
detachPanZoom(); isLoading = false;
detachKeyboard(); // Hands off to +page.svelte, which reveals the text version.
}); onRenderFail?.();
life.dispose();
app.destroy(true, { children: true, texture: true });
}
}); });
// Cleanup on component unmount // Cleanup on component unmount
$effect(() => { $effect(() => {
return () => { return () => life.dispose();
cleanupFunctions.forEach((fn) => fn());
};
}); });
// 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(() => { const tooltipStyle = $derived.by(() => {
let x = tooltipX + 15; const gap = 15;
let y = tooltipY + 15; const rect = tooltipEl?.getBoundingClientRect();
const width = rect?.width || 280;
const height = rect?.height || 120;
// Keep tooltip on screen let x = tooltipX + gap;
const tooltipWidth = 280; let y = tooltipY + gap;
const tooltipHeight = 120;
if (x + tooltipWidth > windowWidth) { // Flip to the other side of the cursor rather than overflow the viewport.
x = tooltipX - tooltipWidth - 15; if (x + width > windowWidth) x = Math.max(0, tooltipX - width - gap);
} if (y + height > windowHeight) y = Math.max(0, tooltipY - height - gap);
if (y + tooltipHeight > windowHeight) {
y = tooltipY - tooltipHeight - 15;
}
return `left: ${x}px; top: ${y}px;`; return `transform: translate3d(${x}px, ${y}px, 0);`;
}); });
</script> </script>
@@ -239,6 +336,7 @@
<!-- Hover Tooltip (hidden on mobile) --> <!-- Hover Tooltip (hidden on mobile) -->
{#if hoveredNode && !modalVisible && !isMobile} {#if hoveredNode && !modalVisible && !isMobile}
<Tooltip <Tooltip
bind:element={tooltipEl}
node={hoveredNode} node={hoveredNode}
style={tooltipStyle} style={tooltipStyle}
colorHex={categoryColorHex[hoveredNode.category]} colorHex={categoryColorHex[hoveredNode.category]}
+32 -2
View File
@@ -1,10 +1,34 @@
<script lang="ts"> <script lang="ts">
import type { TreeNode } from '$lib/data'; import type { TreeNode } from '$lib/data';
const { node, style, colorHex }: { node: TreeNode; style: string; colorHex: string } = $props(); // `let`, not `const`: bind:this assigns to `element`, and a bindable prop is
// only writable when destructured with let.
let {
node,
style,
colorHex,
element = $bindable(null)
}: {
node: TreeNode;
style: string;
colorHex: string;
element?: HTMLDivElement | null;
} = $props();
</script> </script>
<div class="tooltip" role="tooltip" aria-live="polite" style="{style} --node-color: {colorHex};"> <!--
aria-hidden: this is a mouse-only visual duplicate of canvas content. It used
to carry role="tooltip" + aria-live="polite", which announced the full label,
description and "Click for details" on every single hover. Nothing references
it via aria-describedby either — the trigger is a WebGL sprite with no DOM
node — so the role was inert anyway. The accessible path is the text version.
-->
<div
bind:this={element}
class="tooltip"
aria-hidden="true"
style="{style} --node-color: {colorHex};"
>
<h3> <h3>
{#if node.icon} {#if node.icon}
<img src={node.icon} alt="" class="tooltip-icon" /> <img src={node.icon} alt="" class="tooltip-icon" />
@@ -18,6 +42,12 @@
<style> <style>
.tooltip { .tooltip {
position: fixed; position: fixed;
/* Positioned via translate3d, not left/top: moving on the compositor
avoids a layout+paint per frame on an element with two color-mix
shadows. The offsets live in the transform, so these stay at 0. */
top: 0;
left: 0;
will-change: transform;
background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%); background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%);
border: 1px solid var(--node-color); border: 1px solid var(--node-color);
border-radius: 8px; border-radius: 8px;
+10 -1
View File
@@ -6,9 +6,11 @@ import { gsap } from 'gsap';
import { getDistanceFromOrigin } from '$lib/utils/tree'; import { getDistanceFromOrigin } from '$lib/utils/tree';
import { updateParallax } from './interaction'; import { updateParallax } from './interaction';
import type { SkillTreeController } from './controller'; import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
interface EntranceOpts { interface EntranceOpts {
isMobile: boolean; isMobile: boolean;
life: Lifecycle;
/** Called when the welcome title should be removed (matches its CSS fade). */ /** Called when the welcome title should be removed (matches its CSS fade). */
onTitleDone: () => void; onTitleDone: () => void;
} }
@@ -22,6 +24,11 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
const zoomDuration = isMobile ? 2.5 : 4.5; const zoomDuration = isMobile ? 2.5 : 4.5;
const nodeStagger = isMobile ? 0.05 : 0.08; const nodeStagger = isMobile ? 0.05 : 0.08;
// 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 // Hide all lines and plasma beams initially
lineObjects.forEach((line) => { lineObjects.forEach((line) => {
line.graphics.alpha = 0; line.graphics.alpha = 0;
@@ -202,12 +209,13 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
}); });
// Clean up title card after animation (matches titleDuration) // Clean up title card after animation (matches titleDuration)
setTimeout( const titleTimer = setTimeout(
() => { () => {
opts.onTitleDone(); opts.onTitleDone();
}, },
isMobile ? 1500 : 2500 isMobile ? 1500 : 2500
); );
opts.life.add(() => clearTimeout(titleTimer));
// Fade in stats panel toggle button with the tree // Fade in stats panel toggle button with the tree
const toggleButton = document.querySelector('.stats-panel-toggle'); const toggleButton = document.querySelector('.stats-panel-toggle');
@@ -219,4 +227,5 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
ease: 'power2.out' ease: 'power2.out'
}); });
} }
});
} }
+14 -3
View File
@@ -6,9 +6,15 @@ import { gsap } from 'gsap';
import { nebulaPalette } from '$lib/theme'; import { nebulaPalette } from '$lib/theme';
import { PARTICLE_COUNTS, byDevice } from '$lib/config'; import { PARTICLE_COUNTS, byDevice } from '$lib/config';
import type { BackgroundLayers } from './types'; import type { BackgroundLayers } from './types';
import type { Lifecycle } from './lifecycle';
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers { export function createBackground(
app: Application,
isMobile: boolean,
life: Lifecycle
): BackgroundLayers {
const deepBackgroundContainer = new Container(); const deepBackgroundContainer = new Container();
let vignette!: Graphics;
const nebulaContainer = new Container(); const nebulaContainer = new Container();
const starContainer = new Container(); const starContainer = new Container();
@@ -16,6 +22,9 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
app.stage.addChild(nebulaContainer); app.stage.addChild(nebulaContainer);
app.stage.addChild(starContainer); app.stage.addChild(starContainer);
// Everything below allocates `repeat: -1` ambient tweens; building it inside
// the lifecycle's GSAP context makes all of them killable on teardown.
life.animate(() => {
const screenWidth = app.screen.width; const screenWidth = app.screen.width;
const screenHeight = app.screen.height; const screenHeight = app.screen.height;
const centerX = screenWidth / 2; const centerX = screenWidth / 2;
@@ -225,9 +234,10 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
} }
} }
app.ticker.add(driftDust); app.ticker.add(driftDust);
life.add(() => app.ticker.remove(driftDust));
// ========== VIGNETTE OVERLAY ========== // ========== VIGNETTE OVERLAY ==========
const vignette = new Graphics(); vignette = new Graphics();
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2; const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
for (let i = 12; i >= 0; i--) { for (let i = 12; i >= 0; i--) {
@@ -248,6 +258,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
nebulaContainer.interactiveChildren = false; nebulaContainer.interactiveChildren = false;
starContainer.interactiveChildren = false; starContainer.interactiveChildren = false;
vignette.eventMode = 'none'; vignette.eventMode = 'none';
});
return { deepBackgroundContainer, nebulaContainer, starContainer }; return { deepBackgroundContainer, nebulaContainer, starContainer, vignette };
} }
+71 -21
View File
@@ -6,6 +6,7 @@
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config'; import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
import type { SkillTreeController } from './controller'; import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
/** Offset the background layers behind the tree to create parallax depth. */ /** Offset the background layers behind the tree to create parallax depth. */
export function updateParallax(ctrl: SkillTreeController) { export function updateParallax(ctrl: SkillTreeController) {
@@ -25,16 +26,31 @@ export function updateParallax(ctrl: SkillTreeController) {
interface PanZoomOpts { interface PanZoomOpts {
onTooltipMove: (x: number, y: number) => void; onTooltipMove: (x: number, y: number) => void;
/** Tooltip coords are only worth computing while a node is actually hovered. */
hasHover: () => boolean;
} }
/** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */ export interface PanZoomHandle {
export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void { /**
* Push the current pointer position to the tooltip immediately. Needed on
* hover-start: the move handler below only writes state while a node is
* hovered, so without this the first hover would render at stale coords.
*/
flushTooltip: () => void;
}
/** Wire mouse/touch/wheel pan + zoom. Every listener is owned by `life`. */
export function attachPanZoom(
ctrl: SkillTreeController,
opts: PanZoomOpts,
life: Lifecycle
): PanZoomHandle {
const { app, treeContainer } = ctrl; const { app, treeContainer } = ctrl;
const minZoom = ZOOM.min; const minZoom = ZOOM.min;
const maxZoom = ZOOM.max; const maxZoom = ZOOM.max;
// ===== MOUSE PANNING ===== // ===== MOUSE PANNING =====
app.canvas.addEventListener('mousedown', (e: MouseEvent) => { life.listen(app.canvas, 'mousedown', (e: MouseEvent) => {
ctrl.isDragging = true; ctrl.isDragging = true;
ctrl.hasMoved = false; ctrl.hasMoved = false;
ctrl.dragStartX = e.clientX; ctrl.dragStartX = e.clientX;
@@ -55,23 +71,47 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
treeContainer.y = ctrl.containerStartY + dy; treeContainer.y = ctrl.containerStartY + dy;
updateParallax(ctrl); updateParallax(ctrl);
}; };
window.addEventListener('mousemove', onWindowMouseMove); life.listen(window, 'mousemove', onWindowMouseMove);
const onWindowMouseUp = () => { const onWindowMouseUp = () => {
ctrl.isDragging = false; ctrl.isDragging = false;
app.canvas.style.cursor = 'grab'; app.canvas.style.cursor = 'grab';
}; };
window.addEventListener('mouseup', onWindowMouseUp); life.listen(window, 'mouseup', onWindowMouseUp);
app.canvas.style.cursor = 'grab'; app.canvas.style.cursor = 'grab';
// Track mouse position for tooltip // Track mouse position for the tooltip.
app.canvas.addEventListener('mousemove', (e: MouseEvent) => { //
opts.onTooltipMove(e.clientX, e.clientY); // Three things matter here. It is gated on `hasHover()` so moving the mouse
// across empty canvas costs nothing (this previously wrote Svelte state on
// every single mousemove, hovered or not). It coalesces to one write per
// frame instead of one per pointer event. And it listens on `window`, not the
// canvas, so coordinates keep updating when a drag leaves the canvas bounds.
let tooltipFrame = -1;
let tooltipPointerX = 0;
let tooltipPointerY = 0;
const flushTooltip = () => {
tooltipFrame = -1;
opts.onTooltipMove(tooltipPointerX, tooltipPointerY);
};
life.listen(window, 'mousemove', (e: MouseEvent) => {
// Raw coords are always tracked — two plain number writes, negligible.
// What was expensive, and is now gated, is writing Svelte state.
tooltipPointerX = e.clientX;
tooltipPointerY = e.clientY;
if (!opts.hasHover()) return;
if (tooltipFrame === -1) tooltipFrame = requestAnimationFrame(flushTooltip);
});
life.add(() => {
if (tooltipFrame !== -1) cancelAnimationFrame(tooltipFrame);
}); });
// ===== TOUCH PANNING & PINCH ZOOM ===== // ===== TOUCH PANNING & PINCH ZOOM =====
app.canvas.addEventListener( life.listen(
app.canvas,
'touchstart', 'touchstart',
(e: TouchEvent) => { (e: TouchEvent) => {
if (e.touches.length === 1) { if (e.touches.length === 1) {
@@ -94,7 +134,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
{ passive: true } { passive: true }
); );
app.canvas.addEventListener( life.listen(
app.canvas,
'touchmove', 'touchmove',
(e: TouchEvent) => { (e: TouchEvent) => {
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) { if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
@@ -129,7 +170,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
{ passive: true } { passive: true }
); );
app.canvas.addEventListener( life.listen(
app.canvas,
'touchend', 'touchend',
(e: TouchEvent) => { (e: TouchEvent) => {
if (e.touches.length === 0) { if (e.touches.length === 0) {
@@ -150,7 +192,12 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
); );
// ===== WHEEL ZOOM ===== // ===== WHEEL ZOOM =====
app.canvas.addEventListener('wheel', (e: WheelEvent) => { // Explicitly non-passive: this calls preventDefault(). It works by default on
// a non-root element, but relying on that is an implicit dependency.
life.listen(
app.canvas,
'wheel',
(e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
const oldScale = treeContainer.scale.x; const oldScale = treeContainer.scale.x;
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn; const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
@@ -160,12 +207,11 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored. // (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
treeContainer.scale.set(newScale); treeContainer.scale.set(newScale);
updateParallax(ctrl); updateParallax(ctrl);
}); },
{ passive: false }
);
return () => { return { flushTooltip };
window.removeEventListener('mousemove', onWindowMouseMove);
window.removeEventListener('mouseup', onWindowMouseUp);
};
} }
interface KeyboardOpts { interface KeyboardOpts {
@@ -175,8 +221,8 @@ interface KeyboardOpts {
isStatsVisible: () => boolean; isStatsVisible: () => boolean;
} }
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */ /** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void { export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
const { app, treeContainer } = ctrl; const { app, treeContainer } = ctrl;
const minZoom = ZOOM.min; const minZoom = ZOOM.min;
const maxZoom = ZOOM.max; const maxZoom = ZOOM.max;
@@ -241,11 +287,14 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
// R to reset view to origin // R to reset view to origin
if (e.key === 'r' || e.key === 'R') { if (e.key === 'r' || e.key === 'R') {
e.preventDefault(); e.preventDefault();
// Re-enter the GSAP context: these are created per keypress.
life.animate(() => {
gsap.to(treeContainer, { gsap.to(treeContainer, {
x: app.screen.width / 2, x: app.screen.width / 2,
y: app.screen.height / 2, y: app.screen.height / 2,
duration: 0.5, duration: 0.5,
ease: 'power2.out', ease: 'power2.out',
overwrite: 'auto',
onUpdate: () => updateParallax(ctrl), onUpdate: () => updateParallax(ctrl),
onComplete: () => { onComplete: () => {
// Update parallax baseline after reset // Update parallax baseline after reset
@@ -258,11 +307,12 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
y: ctrl.endScale, y: ctrl.endScale,
duration: 0.5, duration: 0.5,
ease: 'power2.out', ease: 'power2.out',
overwrite: 'auto',
onUpdate: () => updateParallax(ctrl) onUpdate: () => updateParallax(ctrl)
}); });
});
} }
} }
window.addEventListener('keydown', handleKeydown); life.listen(window, 'keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
} }
+74
View File
@@ -0,0 +1,74 @@
// Single owner for every resource the tree allocates, so one dispose() call
// releases all of it.
//
// Why this exists: the PixiJS layer creates ~480 infinite GSAP tweens, a ticker
// callback, and a dozen DOM listeners. None of them were ever released, and 112
// of the tweens are created inside an `onComplete` — so they don't exist yet at
// the moment a naive `killTweensOf` sweep would run.
//
// `gsap.context()` solves exactly that: GSAP records each animation's context on
// creation, and restores that context while invoking the animation's callbacks
// (see `_callback` in gsap-core). So a tween created inside a tracked tween's
// onComplete is tracked by the same context, and ctx.kill() gets all of them.
import { gsap } from 'gsap';
export function createLifecycle() {
const disposers: Array<() => void> = [];
const gsapCtx = gsap.context(() => {});
let isDisposed = false;
return {
/** True once dispose() has run — guards async work that resolves late. */
get disposed() {
return isDisposed;
},
/**
* Run `fn` with GSAP's active context set, so every tween it creates is
* tracked for teardown. Returns whatever `fn` returns.
*/
animate<T>(fn: () => T): T {
return gsapCtx.add(fn) as T;
},
/** Register an arbitrary teardown step. */
add(fn: () => void) {
disposers.push(fn);
},
/** Add a DOM listener and register its removal in one call. */
listen<T extends EventTarget>(
target: T,
type: string,
handler: (event: never) => void,
options?: AddEventListenerOptions
) {
target.addEventListener(type, handler as EventListener, options);
disposers.push(() => target.removeEventListener(type, handler as EventListener, options));
},
/**
* Release everything, newest first. `kill()` rather than `revert()`: we do
* not want GSAP writing properties back onto Pixi objects that are about
* to be destroyed.
*/
dispose() {
if (isDisposed) return;
isDisposed = true;
gsapCtx.kill();
for (let i = disposers.length - 1; i >= 0; i--) {
try {
disposers[i]();
} catch (err) {
// One failing teardown step must not strand the rest.
console.warn('Teardown step failed:', err);
}
}
disposers.length = 0;
}
};
}
export type Lifecycle = ReturnType<typeof createLifecycle>;
+16 -5
View File
@@ -8,11 +8,13 @@ import { categoryColors } from '$lib/theme';
import { getNodeRadius } from '$lib/utils/tree'; import { getNodeRadius } from '$lib/utils/tree';
import type { NodeObject } from './types'; import type { NodeObject } from './types';
import type { SkillTreeController } from './controller'; import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
interface NodeOpts { interface NodeOpts {
isMobile: boolean; isMobile: boolean;
isTouchDevice: boolean; isTouchDevice: boolean;
controller: SkillTreeController; controller: SkillTreeController;
life: Lifecycle;
onOpen: (node: TreeNode) => void; onOpen: (node: TreeNode) => void;
onHover: (node: TreeNode | null) => void; onHover: (node: TreeNode | null) => void;
} }
@@ -34,15 +36,22 @@ function attachNodeInteraction(
} }
}); });
// 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', () => { target.on('pointerover', () => {
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2 }); opts.life.animate(() => {
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2 }); 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); if (!opts.isTouchDevice) opts.onHover(node);
}); });
target.on('pointerout', () => { target.on('pointerout', () => {
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2 }); opts.life.animate(() => {
gsap.to(glowGraphics, { alpha: 1, duration: 0.2 }); 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); if (!opts.isTouchDevice) opts.onHover(null);
}); });
} }
@@ -59,7 +68,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
const [profileTexture, iconTextures] = await Promise.all([ const [profileTexture, iconTextures] = await Promise.all([
Assets.load<Texture>('/headshot.jpg'), Assets.load<Texture>('/headshot.jpg'),
// Array form resolves to a url -> Texture map and fetches concurrently. // Array form resolves to a url -> Texture map and fetches concurrently.
iconUrls.length ? Assets.load<Texture>(iconUrls) : Promise.resolve({} as Record<string, Texture>) iconUrls.length
? Assets.load<Texture>(iconUrls)
: Promise.resolve({} as Record<string, Texture>)
]); ]);
for (const node of treeNodes) { for (const node of treeNodes) {
+3 -1
View File
@@ -19,9 +19,11 @@ export interface NodeObject {
glowGraphics: Graphics; glowGraphics: Graphics;
} }
/** The three parallax background layers (vignette lives on the stage). */ /** The parallax background layers plus the vignette overlay. */
export interface BackgroundLayers { export interface BackgroundLayers {
deepBackgroundContainer: Container; deepBackgroundContainer: Container;
nebulaContainer: Container; nebulaContainer: Container;
starContainer: Container; starContainer: Container;
/** Static overlay on the stage; returned so a resize rebuild can destroy it. */
vignette: Graphics;
} }
+109 -15
View File
@@ -42,38 +42,97 @@
// terminate this block early or trip the Svelte ESLint parser. // terminate this block early or trip the Svelte ESLint parser.
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>'; const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
// Set when a new worker is installed and waiting; drives the refresh prompt.
let updateReady = $state<(() => void) | null>(null);
onMount(() => { onMount(() => {
// Register service worker for PWA support if (!('serviceWorker' in navigator)) return;
if ('serviceWorker' in navigator) {
let reloading = false;
// The worker no longer calls skipWaiting() on install, so a new version
// waits instead of taking over a page running the previous deploy's JS.
// When it does take over, reload once so page and worker agree.
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
reloading = true;
location.reload();
});
navigator.serviceWorker navigator.serviceWorker
.register('/service-worker.js') .register('/service-worker.js', { type: 'module' })
.then((registration) => { .then((registration) => {
if (import.meta.env.DEV) { // The old code's comment claimed updates were checked periodically, but
console.log('✨ Service Worker registered successfully:', registration.scope); // nothing ever called update(). Without this a long-lived tab only
} // re-checks on navigation.
const hourly = setInterval(
() => {
registration.update().catch(() => {});
},
60 * 60 * 1000
);
window.addEventListener('pagehide', () => clearInterval(hourly));
// Check for updates periodically
registration.addEventListener('updatefound', () => { registration.addEventListener('updatefound', () => {
const newWorker = registration.installing; const newWorker = registration.installing;
if (newWorker) { if (!newWorker) return;
newWorker.addEventListener('statechange', () => { newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { if (newWorker.state !== 'installed' || !navigator.serviceWorker.controller) return;
if (import.meta.env.DEV) { // A new version is ready and waiting. Previously this only logged,
console.log('🔄 New service worker available. Refresh to update.'); // and in DEV only, so the SKIP_WAITING handler was dead code.
} updateReady = () => newWorker.postMessage({ type: 'SKIP_WAITING' });
}
}); });
}
}); });
}) })
.catch((error) => { .catch((error) => {
console.warn('Service Worker registration failed:', error); console.warn('Service Worker registration failed:', error);
}); });
}
}); });
</script> </script>
<svelte:head> <svelte:head>
<!--
With JS disabled the canvas never mounts and SeoContent stays clipped to
1x1px, so the page renders pure black. This reveals the text version with
no JS involved.
-->
<noscript>
<style>
.sr-only {
position: static !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
overflow: visible !important;
clip: auto !important;
white-space: normal !important;
max-width: 60rem;
padding: 2rem 1.5rem;
color: #e8e8f0;
line-height: 1.6;
}
html,
body {
overflow: auto !important;
}
.sr-only a {
color: #ffd700;
}
.sr-only h1,
.sr-only h2,
.sr-only h3 {
color: #ffd700;
margin: 1.25rem 0 0.5rem;
}
.sr-only ul {
padding-left: 1.25rem;
}
main {
display: none;
}
</style>
</noscript>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
@@ -150,3 +209,38 @@
</svelte:head> </svelte:head>
{@render children()} {@render children()}
{#if updateReady}
<div class="update-toast" role="status">
<span>A new version is available.</span>
<button onclick={() => updateReady?.()}>Refresh</button>
</div>
{/if}
<style>
.update-toast {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 1100;
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 1rem;
border: 1px solid var(--color-gold);
border-radius: 8px;
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
color: #e8e8f0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
}
.update-toast button {
padding: 0.35rem 0.75rem;
border: 0;
border-radius: 6px;
background: var(--color-gold);
color: #12121a;
font-weight: 700;
cursor: pointer;
}
</style>
+11 -3
View File
@@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import SkillTree from '$lib/components/SkillTree.svelte'; import SkillTree from '$lib/components/SkillTree.svelte';
import SeoContent from '$lib/components/SeoContent.svelte'; import SeoContent from '$lib/components/SeoContent.svelte';
// If the canvas can't come up (no WebGL, asset failure), the text version
// stops being a hidden mirror and becomes the page.
let renderFailed = $state(false);
</script> </script>
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). --> <!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
<SeoContent /> <SeoContent revealed={renderFailed} />
<main> <main class:hidden={renderFailed}>
<SkillTree /> <SkillTree onRenderFail={() => (renderFailed = true)} />
</main> </main>
<style> <style>
@@ -18,4 +22,8 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
main.hidden {
display: none;
}
</style> </style>
+120
View File
@@ -0,0 +1,120 @@
/// <reference types="@sveltejs/kit" />
// Lives in src/, not static/, so SvelteKit builds it and injects the manifest
// below. That matters: `build` and `files` are generated from the actual output,
// so the precache list can't drift, and `version` changes every deploy, so cache
// names rotate automatically instead of needing a hand-edited version string.
import { build, files, version } from '$service-worker';
const CACHE = `atlas-of-skills-${version}`;
// Content-hashed build output + everything in static/. Hashed assets are
// immutable, so once cached they never need revalidating.
const PRECACHE = [...build, ...files];
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
// Individually, not cache.addAll(): addAll is atomic, so a single 404
// failed the whole install and left the site with no offline support at
// all — silently.
const results = await Promise.allSettled(
PRECACHE.map((asset) => cache.add(new Request(asset, { cache: 'reload' })))
);
const failed = results.filter((r) => r.status === 'rejected').length;
if (failed) {
console.warn(`[sw] ${failed}/${PRECACHE.length} assets failed to precache`);
}
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Drop every previous version's cache. Because the name embeds `version`,
// this now actually prunes on each deploy instead of accumulating stale
// hashed chunks forever.
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)));
await self.clients.claim();
})()
);
});
/** Assets emitted by the build are content-hashed, so cache-first is safe. */
const PRECACHED = new Set(PRECACHE);
self.addEventListener('fetch', (event) => {
const { request } = event;
// Never touch non-GET (cache.put throws on them) or cross-origin.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
event.respondWith(handle(event, url));
});
async function handle(event, url) {
const { request } = event;
const cache = await caches.open(CACHE);
// Content-hashed build output: cache-first, it can never go stale.
if (PRECACHED.has(url.pathname) && build.includes(url.pathname)) {
const hit = await cache.match(url.pathname);
if (hit) return hit;
}
// Everything else — navigations and unhashed static files (icons, headshot,
// the résumé PDF) — is network-first. The old worker served these cache-first
// with no expiry, which meant replacing the résumé at the same URL never
// reached anyone who had already visited.
try {
const response = await fetch(request);
// Only cache real successes. A 404/500 body used to get written and then
// served as the offline fallback. Opaque/redirected responses throw on
// cache.put, so they're excluded too.
if (response.status === 200 && response.type === 'basic' && !response.redirected) {
// waitUntil so the write survives worker termination.
event.waitUntil(cache.put(request, response.clone()));
}
return response;
} catch {
const hit = await cache.match(request);
if (hit) return hit;
// Navigations fall back to the cached shell.
if (request.mode === 'navigate') {
const shell = await cache.match('/');
if (shell) return shell;
}
// Every other failure must still resolve to a Response. Returning
// undefined here (the previous behaviour for non-image assets) makes
// respondWith throw a TypeError and turns a soft failure into a hard one.
if (request.destination === 'image') {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">' +
'<rect width="120" height="120" fill="#05050a"/>' +
'<text x="60" y="64" fill="#ffd700" font-family="sans-serif" font-size="12" text-anchor="middle">Offline</text>' +
'</svg>',
{ status: 503, headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' } }
);
}
return new Response('Offline', {
status: 503,
headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }
});
}
}
// The page posts this after the user accepts an update prompt. Deliberately no
// skipWaiting() in install: taking over a page whose JS came from the previous
// deploy is how you get version skew.
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
-116
View File
@@ -1,116 +0,0 @@
// Service Worker for PWA offline support
const CACHE_NAME = 'atlas-of-skills-v2';
const RUNTIME_CACHE = 'atlas-runtime-v2';
// Assets to cache on install
const PRECACHE_ASSETS = [
'/',
'/headshot.jpg',
'/kevin-riehl-resume.pdf',
'/robots.txt',
// Add icon paths when they're created
'/icons/pwa.svg'
];
// Install event - precache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
.map((name) => caches.delete(name))
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - network first, fallback to cache
self.addEventListener('fetch', (event) => {
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
// Network first strategy for HTML
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone response to store in cache
const responseClone = response.clone();
caches.open(RUNTIME_CACHE)
.then((cache) => cache.put(event.request, responseClone));
return response;
})
.catch(() => {
// Fallback to cache if network fails
return caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Return offline page if available
return caches.match('/');
});
})
);
return;
}
// Cache first strategy for assets (CSS, JS, images, icons)
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Not in cache, fetch from network
return fetch(event.request)
.then((response) => {
// Don't cache if not a success response
if (!response || response.status !== 200 || response.type === 'error') {
return response;
}
// Clone response to store in cache
const responseClone = response.clone();
caches.open(RUNTIME_CACHE)
.then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Return fallback for images if network and cache fail
if (event.request.destination === 'image') {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect fill="#05050a"/><text x="50%" y="50%" text-anchor="middle" fill="#ffd700">Offline</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
});
})
);
});
// Message event - allow clients to skip waiting
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});