Files
skill-tree-portfolio/src/lib/components/SeoContent.svelte
T
TehRiehlDealandClaude Opus 5 746d81b151 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>
2026-08-09 00:27:30 -07:00

127 lines
4.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}