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>
127 lines
4.1 KiB
Svelte
127 lines
4.1 KiB
Svelte
<script lang="ts">
|
||
// Screen-reader / crawler-only rendition of the portfolio.
|
||
//
|
||
// The interactive tree lives entirely on a PixiJS canvas, which search
|
||
// engines and assistive tech can't read. This component renders the same
|
||
// content (sourced from data.ts — the single source of truth) as real,
|
||
// prerendered semantic HTML, visually hidden via the global `.sr-only` class.
|
||
import {
|
||
treeNodes,
|
||
type ExperienceNode,
|
||
type ProjectNode,
|
||
type EducationNode,
|
||
type OriginNode
|
||
} from '$lib/data';
|
||
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');
|
||
|
||
// Leaf nodes only — container/branch nodes lack these category fields.
|
||
const jobs = treeNodes.filter(
|
||
(n): n is ExperienceNode => n.category === 'experience' && !!n.company
|
||
);
|
||
const projects = treeNodes.filter(
|
||
(n): n is ProjectNode => n.category === 'projects' && !!n.techStack?.length
|
||
);
|
||
const education = treeNodes.filter(
|
||
(n): n is EducationNode => n.category === 'education' && !!n.institution
|
||
);
|
||
|
||
// Newest experience first (reverse-chronological, résumé convention).
|
||
const jobsNewestFirst = [...jobs].reverse();
|
||
|
||
const skillsByBranch = aggregateSkills();
|
||
</script>
|
||
|
||
{#if origin}
|
||
<section class="sr-only" class:revealed aria-label="Portfolio (text version)">
|
||
<h1>{origin.fullName} — {origin.jobTitle}</h1>
|
||
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
|
||
{#if origin.location}<p>Location: {origin.location}</p>{/if}
|
||
|
||
<nav aria-label="Contact and profiles">
|
||
<ul>
|
||
{#if origin.email}<li><a href="mailto:{origin.email}">Email: {origin.email}</a></li>{/if}
|
||
{#if origin.linkedIn}<li><a href={origin.linkedIn}>LinkedIn</a></li>{/if}
|
||
{#if origin.github}<li><a href={origin.github}>GitHub</a></li>{/if}
|
||
{#if origin.website}<li><a href={origin.website}>Website</a></li>{/if}
|
||
{#if origin.resumeUrl}<li><a href={origin.resumeUrl}>Résumé (PDF)</a></li>{/if}
|
||
</ul>
|
||
</nav>
|
||
|
||
<section aria-label="Work experience">
|
||
<h2>Work Experience</h2>
|
||
{#each jobsNewestFirst as job (job.id)}
|
||
<article>
|
||
<h3>{job.jobTitle} — {job.company}</h3>
|
||
{#if job.startDate}<p>{job.startDate} – {job.endDate || 'Present'}</p>{/if}
|
||
{#if job.highlights?.length}
|
||
<ul>
|
||
{#each job.highlights as highlight (highlight)}
|
||
<li>{highlight}</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</article>
|
||
{/each}
|
||
</section>
|
||
|
||
<section aria-label="Projects">
|
||
<h2>Projects</h2>
|
||
{#each projects as project (project.id)}
|
||
<article>
|
||
<h3>{project.label}</h3>
|
||
<p>{project.description}</p>
|
||
{#if project.techStack?.length}
|
||
<p>Tech: {project.techStack.join(', ')}</p>
|
||
{/if}
|
||
<ul>
|
||
{#if project.projectUrl}<li><a href={project.projectUrl}>Live site</a></li>{/if}
|
||
{#if project.repoUrl}<li><a href={project.repoUrl}>Source code</a></li>{/if}
|
||
{#if project.pypiUrl}<li><a href={project.pypiUrl}>PyPI package</a></li>{/if}
|
||
{#if project.npmUrl}<li><a href={project.npmUrl}>npm package</a></li>{/if}
|
||
</ul>
|
||
</article>
|
||
{/each}
|
||
</section>
|
||
|
||
<section aria-label="Technical skills">
|
||
<h2>Technical Skills</h2>
|
||
{#each BRANCH_IDS as branchId (branchId)}
|
||
{@const branch = skillsByBranch[branchId]}
|
||
{#if branch && branch.skills.length}
|
||
<h3>{branch.label}</h3>
|
||
<ul>
|
||
{#each branch.skills as skill (skill.id)}
|
||
<li>
|
||
{skill.label}{#if skill.proficiency}
|
||
({skill.proficiency}{#if skill.yearsOfExperience}, {skill.yearsOfExperience} years{/if})
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{/each}
|
||
</section>
|
||
|
||
<section aria-label="Education">
|
||
<h2>Education</h2>
|
||
{#each education as edu (edu.id)}
|
||
<article>
|
||
<h3>
|
||
{edu.degree}{#if edu.field}
|
||
in {edu.field}{/if}
|
||
</h3>
|
||
<p>
|
||
{edu.institution}{#if edu.graduationYear}, {edu.graduationYear}{/if}
|
||
</p>
|
||
</article>
|
||
{/each}
|
||
</section>
|
||
</section>
|
||
{/if}
|