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>
121 lines
4.5 KiB
JavaScript
121 lines
4.5 KiB
JavaScript
/// <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();
|
|
});
|