Author SHA1 Message Date
TehRiehlDealandClaude Opus 5 c4afe501b6 fix: stop the endless "new version available" prompt
The update prompt reappeared after every refresh, forever.

Root cause: the worker was registered with `{ type: 'module' }`, but
SvelteKit/Vite bundles src/service-worker.js down to a classic script with no
imports or exports (verified: zero import/export statements in the build
output). Registering a classic script as a module made the browser install a
fresh worker on every register() call — so every page load produced a
"waiting" worker, which is precisely the signal the prompt keys off. Clicking
Refresh activated that worker and the next load did the same thing again.

Reproduced deterministically before the fix: with byte-identical script bytes
(confirmed by hashing two fetches), a single registration and a single scope,
every load still ended with waiting: "installed" and the prompt visible.
After removing the option, three consecutive loads of the same build leave no
waiting worker and no prompt.

Two related defects found while investigating:

- controllerchange fired a full page reload on a FIRST visit. The worker calls
  clients.claim(), so a page that starts with no controller gets one, which is
  a controllerchange — every new visitor was silently getting a redundant
  reload. Now guarded on whether the page had a controller to begin with.

- A worker already waiting from a previous visit was invisible. `updatefound`
  does not fire for one that is already installed, so someone who closed the
  tab with an update pending would never be offered it again. The waiting
  worker is now checked at registration time.

Also clears the prompt when Refresh is clicked rather than leaving it up
during the hand-over.

Verified the full lifecycle against two real builds: unchanged build across
repeated loads shows no prompt; a genuinely new build shows it; clicking
Refresh activates the worker and the prompt does not return on reload; the
previous version's cache is pruned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:16:53 -07:00
TehRiehlDealandClaude Opus 5 85086e31b2 feat: audit phase 6 (T4) — node allocation, the Path of Exile payoff
The site borrowed PoE's visual language — passive-tree layout, plasma
connections, an RPG stats panel — without any of its mechanic. Nodes were
purely decorative. This is the interaction the concept was promising.

- New utils/allocation.ts: path-to-root walking, allocate/deallocate, edge
  state, and URL encode/decode. Pure, 17 unit tests.
- Allocation is path-connected like the game: allocating a leaf brings its
  whole path, and deallocating a node removes its descendants so nothing is
  ever orphaned. pathToRoot is cycle-safe, and a test asserts every real node
  reaches the origin.
- New pixi/allocation.ts: the path reads as "lit" by dimming everything else
  rather than brightening the selection — the tree is already near its
  brightness ceiling, so recession is the only headroom. Nodes dim via alpha
  (nothing tweens it after the entrance); connections dim via tint, because
  their alpha is driven by the permanent plasma pulse and writing to it would
  fight the animation every frame.
- Shareable via ?build=. Encoded as node ids, not indices: a bitset would be
  shorter, but any edit to data.ts would silently re-point every previously
  shared link at the wrong nodes. Decoding re-derives each path, so a
  hand-edited or partially-stale link can't produce a broken build, and
  unknown ids are dropped rather than throwing.
- Allocation hangs off a button in the modal rather than the node click,
  which already opens that modal. BuildBar shows the count with share/reset;
  share falls back to rewriting the address bar when the clipboard is blocked.

Two bugs found while verifying:
- Both deep links (?build and ?tour) had ended up nested inside the
  first-visit coach-hint setTimeout, so a shared link did nothing at all for
  a returning visitor and waited out the whole intro for everyone else —
  backwards, since a shared link is precisely the cold-arrival case. Hoisted
  to run immediately, unconditionally. A ?tour link now also skips the intro.
- A restored ?build= was wiped by the entrance, which tweens every node's
  alpha to 1 after the deep link applies. The result was a full-brightness
  tree with a counter claiming 4 allocated. Now re-applied once the entrance
  finishes.

Verified in the production build: allocating TypeScript lights
origin -> Skills -> Languages -> TypeScript and dims the rest; the counter
reads 4; the generated link restores exactly that state on a cold load; Reset
clears it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:01:53 -07:00
TehRiehlDealandClaude Opus 5 aa1cda8245 feat: audit phase 6 (T3) — Journey Mode, a guided career narrative
Browsing a skill tree is entirely self-directed: a visitor has to know what
to click and in what order, and most won't. The strongest material (WebRTC
signaling, MediaPipe accessibility, air-gapped deployment tooling) sat
several clicks deep with nothing pointing at it.

Journey Mode flies the camera along the experience arc oldest-first — so the
career reads as progression rather than a résumé's reverse chronology —
surfacing up to two highlights per stop.

- New utils/journey.ts: stops derived from data.ts, so the tour can never
  drift from the tree. 12 unit tests cover ordering (oldest-first), that only
  real jobs appear (no container nodes), that it ends on the current role,
  slug uniqueness, and deep-link resolution.
- New JourneyMode.svelte: progress pips, prev/next/pause, keyboard
  (arrows/space/escape), focus moved into the panel and restored on exit,
  aria-live so each stop is announced as the tour advances.
- Deep-linkable: ?tour=peraton opens on that role, ?tour on its own starts
  from the beginning. Slug matching is case- and punctuation-insensitive.
- panToNode now takes a target scale and duration, so each stop settles at a
  readable zoom rather than wherever the user left it. It also re-baselines
  the parallax origin on completion, and drives updateLabels during the
  flight so labels stay in step.
- Launch from the HUD ("Take the 60-second tour") or the J key. The keyboard
  handler yields entirely to the journey while it runs, as it already did
  for the search palette.

Reduced motion starts the tour paused rather than auto-advancing —
auto-advancing content that moves the camera is precisely what that
preference is asking us not to do — and panToNode jumps instead of flying.
The tour stays fully available, just user-driven.

Fixed while verifying: the first-run coach hint occupies the same
bottom-centre slot and was rendering on top of the journey's controls. It now
yields while the tour is active, and starting a tour retires it.

Verified in the production build: ?tour=peraton lands on stop 6/6 with the
camera on the Peraton node; Next/Prev/Pause all step correctly (1/6 -> 2/6 ->
3/6 -> back to 2/6); focus lands in the panel; the hint is suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:50:47 -07:00
TehRiehlDealandClaude Opus 5 ea721ae650 feat: audit phase 6 (T2) — on-canvas labels and Cmd/Ctrl-K search
Even with the HUD from T1, the tree itself was 57 wordless icons: no way to
answer "does he know NestJS?" without clicking around.

Search palette (Cmd/Ctrl-K):
- New utils/search.ts — pure, 9 unit tests. Subsequence matching on short
  fields ("wbrtc" finds WebRTC), plus tech-stack and company matching so
  "Prisma" surfaces the projects using it and "Peraton" finds the role.
- Descriptions deliberately use substring, NOT subsequence matching. Over
  long prose a subsequence match is almost always spurious — the first
  version matched "Prisma" as p...r...i...s...m...a spread across an
  unrelated sentence, which a test caught.
- Selecting a result flies the camera to that node and opens its modal.
- Built to the ARIA combobox pattern: input keeps focus and owns
  aria-activedescendant, results are a listbox. Options intentionally have no
  keyboard handler — making them focusable would break the pattern — so the
  svelte-ignore there is documented rather than incidental.

On-canvas labels:
- A Text per node, hidden below LABEL_ZOOM_THRESHOLD and counter-scaled by
  1/zoom so they stay a constant size on screen rather than ballooning.
- Threshold sits just above the settled desktop zoom, so the default view
  stays a clean constellation and zooming in reveals labels. 'L' forces them
  on at any zoom.
- Long labels truncate at 18 chars; full text is still in the tooltip/modal.
- Collision culling: labels are placed biggest-node-first, and any whose box
  would overlap an already-placed label or someone else's node is dropped.
  Dense clusters simply show fewer labels until you zoom further in. This is
  purely presentational — no node coordinates changed.

Three bugs found during review:
- The entrance zoom tween only called updateParallax, so the label
  counter-scale stayed frozen at its pre-zoom value (labels ended up at 1/4
  size and effectively vanished). It now updates labels too, as does the
  skip/reduced-motion path that sets scale directly.
- updateLabels forced alpha = 1, so labels appeared over nodes that had not
  faded in yet — visible during the splash screen. Alpha now belongs to the
  entrance and labels arrive with their node.
- animation.ts imported updateParallax only; it needed updateLabels as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:42:51 -07:00
TehRiehlDealandClaude Opus 5 d8297cf903 feat: audit phase 6 (T1) — persistent HUD, skip intro, faster entrance
The audit's headline UX finding, measured on production: cold load showed a
black screen for ~5s, then a wordless constellation. At 9 seconds the number
of words on screen was zero — no name, no title, no hint that anything was
clickable. A recruiter could not learn one fact without clicking.

- New Hud.svelte: name, market-legible title and a one-line hook, always on
  screen, sourced from the origin node. Readable at ~1s instead of never.
  Added `tagline` to OriginNode so it lives with the rest of the content.
- New CoachHint.svelte: first-run "drag / scroll / click" hint. The icons
  gave no affordance that the tree was interactive at all.
- Skip intro control. runEntranceAnimations now returns a handle whose skip()
  enumerates the entrance's own nested GSAP context and calls progress(1) on
  each finite tween — which fires their onComplete, so node interactivity and
  the ambient pulses come up exactly as if the intro had played through.
  Verified: a node is clickable immediately after skipping.
- Returning visitors land on the settled tree (localStorage), reusing the
  same code path as reduced motion. The flag is set once the intro has
  actually been seen, not at mount — otherwise someone who bounced after a
  second would be marked as having seen it and never get it.
- Entrance timings cut: title 2.5s -> 1.6s, zoom 4.5s -> 2s, stagger 0.08 ->
  0.045 (that last one was 4.5s of cascade on its own across 56 nodes).

Two bugs found while building this:
- animation.ts hardcoded its timings as literals instead of reading
  config.ts TIMINGS, so editing config silently did nothing. Now derived.
- Skip and the coach hint were timed off a magic 3200ms, so Skip vanished
  while the intro was still running and "Click a node" appeared before the
  nodes did. Both now derive from the entrance's real computed length, which
  the handle exposes as durationMs.
- Skip sat bottom-right underneath the service-worker update toast (same z
  token, later in DOM order). Moved to bottom-centre; the toast got its own
  layer token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:24:13 -07:00
TehRiehlDealandClaude Opus 5 c4f1fd9f84 style: audit phase 5 — real typography, design tokens, AA contrast
Typography:
- The codebase contained exactly one font-family declaration
  (LoadingIndicator), naming 'Cinzel Decorative' and 'Trajan Pro' — neither
  loaded, bundled, or present in static/. So every word on the site, in every
  component, rendered in the browser's default serif. For a site whose whole
  premise is atmosphere, that was the largest craft gap.
- Now a display/body pairing: self-hosted Cinzel Variable for the name,
  headings, welcome title and tooltip titles; a system stack for body and UI
  copy, which costs nothing to load and is far more legible than Times at the
  13-14px the modal and stats panel use.
- @fontsource-variable/cinzel ships two subsets with unicode-range, so only
  the 25.3KB latin file is fetched for this content (verified — latin-ext is
  emitted but never requested). font-display: swap, so text never blocks.

Contrast (WCAG 1.4.3):
- Measured every grey against the panel background (#1a1a2e) rather than
  eyeballing it. #666 is 2.97:1 and fails AA for normal text; it was used for
  the modal close glyph, the tooltip hint, the branch count and the skill
  years. Replaced with --text-subtle (#9a9aa8, 6.1:1).
- Correction to the audit: #888 was listed as failing. It measures 4.81:1 and
  passes. Tokenised for consistency, not because it was broken.
  --text-muted (#a8a8b8) is 7.3:1, not the ~9:1 I claimed.

Tokens:
- z-index was nine ad-hoc literals across seven files, with LoadingIndicator
  and the modal backdrop both on 1000. Now a named scale, and no raw z-index
  literal survives anywhere in src/ (verified against the live stylesheet).
- Added spacing, radius, motion and type tokens alongside the existing colour
  ones, so the system covers more than colour.

Note: package-lock.json in this commit also carries pre-existing churn from
before this work; adding the font dependency entangled the two.

Verified in the production build: Cinzel loads and applies to headings,
computed body font resolves to system-ui, one font file fetched, zero raw
z-index literals in the rendered CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:11:21 -07:00
TehRiehlDealandClaude Opus 5 75d0584952 a11y: audit phase 3 — keyboard access, reduced motion, focus management
The site claimed comprehensive accessibility. It had a Level A keyboard
failure, no reduced-motion support of any kind, and a dialog whose ARIA
actively lied about the page state.

Keyboard access to nodes (WCAG 2.1.1, Level A):
- Nodes are PixiJS Containers with no DOM representation, so there was no way
  to reach or activate one by keyboard. The only keyboard vocabulary was
  pan/zoom/reset. New TreeNavigator.svelte renders the same data.ts as a real
  WAI-ARIA tree: roving tabindex, Up/Down traversal, Right/Left to expand and
  collapse, Home/End, Enter/Space to open the same modal a click opens.
  Focusing a row pans the canvas to that node so the two stay in sync.
- The canvas advertised role="application", which tells screen readers to
  suppress their own navigation and forward every key — appropriate only for
  a widget implementing a full keyboard model, which it did not. Now
  role="img" with a description, still focusable for pan/zoom.
- .sr-only content is revealed on :focus-within. Previously ~20 links were in
  the tab order while clipped to 1x1px, so a sighted keyboard user tabbed into
  nothing (WCAG 2.4.7). Added a skip link as the first focusable element.

Focus management:
- The modal set aria-modal="true" — telling AT that everything outside does
  not exist — while focus stayed on the canvas behind it and Tab walked the
  hidden text version underneath. Focus now moves into the dialog on open, is
  trapped there, and is restored to the trigger on close.
- Removed two onkeydown Escape handlers (modal + stats backdrop) that could
  never fire: a role="presentation" div with no tabindex is never a keydown
  target. Their aria-labels were discarded by the role too.
- The 300ms mobile touch-bleed guard was applied to every close path, so
  Escape or the X immediately after opening silently did nothing. It now
  guards only outside-dismissal, which is what it was for.

Reduced motion (WCAG 2.3.3 / 2.2.2):
- Previously respected nowhere, on a site built from a 4.5s forced zoom, an
  ~8s staggered entrance, ~480 looping tweens and a continuous dust ticker.
  The entrance now jumps to the finished state, the dust ticker is not
  registered, and all six ambient loops route through one guard that simply
  never creates them. Plus the CSS media query the GSAP tweens can't reach.

Other:
- StatsPanel used aria-hidden while containing tabindex="0" children — an axe
  aria-hidden-focus violation. Now `inert`, which removes it from the a11y
  tree and the tab order together.
- Branch headers were div+role="button" handling Enter but not Space. Now
  real buttons with aria-controls.
- The global keydown listener had no scope guard: it preventDefault()ed arrow
  keys unconditionally, so the 380px scrollable stats panel could not be
  scrolled by keyboard, and Ctrl combos were not excluded. Ctrl+wheel (browser
  and trackpad zoom) is no longer swallowed either.
- Focus rings lived in SkillTree's *scoped* style block, so they never reached
  NodeModal or StatsPanel children. Moved to app.css.
- Second <h1> in StatsPanel demoted; the page heading is SeoContent's.
- getBeamIntensity used a truthiness check on yearsOfExperience, so 0 fell
  through to the default.

Tests 16 -> 27: reduced-motion helper (all four branches), hierarchy building
including cycle/orphan handling, and a data-integrity check that every
parentId resolves to a real node.

Verified in the production build: Tab reveals the text version with a visible
focus ring; arrow keys traverse the tree and expand/collapse (rows 5->6);
Enter opens the modal with focus landing on Close; Shift+Tab stays trapped;
Escape restores focus to the originating tree item.

Not verified end-to-end: the reduced-motion JS branch. This environment can't
emulate the media query — patching matchMedia after navigation always races
module init — so that branch is covered by unit tests and review, not
observation. Worth a manual pass with DevTools > Rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:03:36 -07:00
TehRiehlDealandClaude Opus 5 f83a317520 content: audit phase 4 — lead with the work that differentiates
Brand/content phase. The engineering was never the weak part of this site;
the copy was. Decisions on title, bio angle, download figures and node copy
were the owner's — this implements them.

Identity:
- jobTitle was 'Software Development - Lead Associate', an internal HR grade
  that means nothing to a recruiter or a search engine. Now 'Lead Software
  Engineer' — a faithful translation, not a promotion.
- aboutMe opened with "Intermediate Software Engineer", grading himself down
  in his own headline, and named the most generic possible stack
  (React/Node/SQL/ShadCN/Docker). It now leads with the work almost nobody
  else has done: WebRTC telehealth, real-time gesture recognition that made
  chat usable for speech-impaired patients, and air-gapped deployment
  tooling. Those were previously buried 3-4 clicks deep in leaf nodes and
  appeared nowhere on the surface.
- Title/description/OG/Twitter copy realigned to match. JSON-LD needed no
  change — it already derives from the origin node.

Node copy:
- The five job nodes had placeholder descriptions ('Junior developer at the
  start of my career.', 'Where I am now.') that are the first thing a tooltip
  shows. Each is now a one-line hook naming the most substantial thing from
  that role.
- Normalised highlight punctuation: only exp-job5's bullets ended in periods.

Proof of work:
- `downloads` existed on ProjectNode and was used by zero nodes despite two
  published PyPI packages. Populated with verified lifetime figures from
  pypistats.org (tvdbAPI 2,277, tmdbAPI 784, checked 2026-08-09) and dated in
  a comment. Lifetime, deliberately, not the recent rate — 27 and 12 in the
  last month is not a number worth advertising.
- Nothing rendered the field. NodeModal now shows it beside the status badge,
  and SeoContent renders it too so it's crawlable.

Data integrity:
- edu-degree's description said "Computer Science" while its own structured
  fields said Applied Science / IT-Software Development. Its label was the
  generic "University"; now Green River College.
- calculatePlayerLevel() hardcoded '2020-10-01', duplicating
  exp-job1.startDate — two sources for one fact, free to drift. It now
  derives the earliest start from the experience nodes, via an explicit
  "Month YYYY" parser rather than handing a bare "October 2020" to
  `new Date` (not a format engines are required to accept).
- Added the first tests for calculatePlayerLevel, previously the only
  untested export in stats.ts. 14 -> 16 tests.
- Fixed the 'tool-vscod' id typo.

Corrections to the audit this phase acted on: the self-hosted repos at
git.tehriehldeal.com ARE publicly reachable (all three return 200
anonymously) — the earlier claim that recruiters couldn't reach them was
asserted without checking. All three live project sites are up too.

Verified in the production build: modal shows the new title and bio, the
downloads badge renders as "↓ 2,277 downloads", figures are crawlable in the
prerendered HTML, and no filler copy survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:42:45 -07:00
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
TehRiehlDealandClaude Opus 5 8874b35998 perf+fix: audit phase 0/1 — parallel textures, dead weight, two real bugs
Findings from a full frontend audit (static read + live instrumentation of
production). This covers the "bleeding" and performance phases.

Bug fixes:
- StatsPanel branch collapse/expand never re-rendered. Two Svelte 5 defects:
  $state(new Set()) is not proxied (proxy.js bails on non-object/array
  prototypes), so .add()/.delete() were untracked; and the Svelte 4
  `x = x` reactivity idiom is a no-op in runes mode, where sources use a
  strict `equals` comparator. Switched to SvelteSet and dropped the
  self-assignment. Verified: aria-expanded now flips and rows 28<->24.
- The closed stats panel's gold box-shadow (8px offset + 80px blur) bled
  ~88px past its right edge, painting a permanent olive stripe down the
  left of the viewport on every page view. Offset the closed transform
  past the shadow.
- data.ts shipped a placeholder repoUrl (github.com/yourusername/...), a
  live 404 on the node describing this very site. Also removed the
  "Tailwind CSS" techStack claim; it is not a dependency of this project.

Performance:
- nodes.ts awaited Assets.load() inside the node loop, serialising ~56 icon
  round-trips before the tree could render or accept input. Now one
  parallel batch. Measured: all 78 tree icon requests start at ~28ms and
  complete within ~618ms.
- isLoading was cleared right after app.init(), before a single texture
  existed, so the spinner resolved onto an empty screen. Moved to after
  the scene graph is built.
- Renderer left antialias on (costly, ~no benefit on soft glow circles)
  and resolution/autoDensity at defaults (blurry on HiDPI). Now
  antialias:false, resolution:min(dpr,2), autoDensity, high-performance.
- cacheAsTexture on the fully-static gradient (16 full-screen fills) and
  vignette (13); interactiveChildren:false on background containers.
- Dust ticker allocated a fresh closure per frame via forEach; replaced
  with a named function and an indexed loop.

Dead weight:
- static/{headshot,profile}.jpg.original (8.6MB, 83% of the static payload)
  were committed and deployed verbatim by adapter-static, publicly
  fetchable and referenced by nothing. Moved to a gitignored assets-src/
  so the full-res sources stay on disk without shipping.
- precompress:true so nginx gzip_static/brotli_static have siblings to
  serve (the origin currently sends everything uncompressed).

build/ 11.13MB -> 3.7MB. JS wire 727.6KB raw -> 232KB gzip / 202.7KB brotli.
svelte-check clean, 14/14 tests pass.

Note: the matching nginx change lives in the gitignored homelab/default.conf
and must be applied to the container separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:06:01 -07:00
47 changed files with 4129 additions and 1109 deletions

No files matched your search

+5
View File
@@ -27,3 +27,8 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
homelab/*
# Full-res source images — kept locally, never deployed (adapter-static copies all of static/)
assets-src/
*.original
+557 -761
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -36,6 +36,7 @@
"vitest": "^4.1.8"
},
"dependencies": {
"@fontsource-variable/cinzel": "^5.3.0",
"gsap": "^3.14.2",
"pixi.js": "^8.15.0"
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"group:allNonMajor"
]
}
+166
View File
@@ -11,7 +11,62 @@
--bg-space: #05050a;
--panel-from: #1a1a2e;
--panel-to: #0f0f1a;
/*
* Text colours, with measured contrast against --panel-from (#1a1a2e):
* --text-primary 14.0:1
* --text-muted 7.3:1
* --text-subtle 6.1:1
* The previous #666 used for hints, counts and the modal close glyph was
* 2.97:1 — below the 4.5:1 AA threshold for normal text.
*/
--text-primary: #e8e8f0;
--text-muted: #a8a8b8;
--text-subtle: #9a9aa8;
/*
* Type. Cinzel is a Trajan-style inscriptional face — display sizes only;
* body copy stays on the system stack, which costs nothing to load and is
* far more legible at 13-14px. Before this, the single font-family
* declaration in the codebase named two fonts that were never loaded, so
* every word rendered in the browser's default serif.
*/
--font-display: 'Cinzel Variable', 'Cinzel', 'Trajan Pro', Georgia, serif;
--font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
/* Radii */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
/*
* Layering. These were ad-hoc literals scattered across seven files, with
* LoadingIndicator and the modal backdrop both sitting on 1000.
*/
--z-welcome: 100;
--z-tooltip: 500;
--z-panel-backdrop: 899;
--z-panel: 900;
--z-panel-toggle: 950;
--z-modal: 1000;
--z-toast: 1100;
--z-update-toast: 1150;
--z-tree-nav: 1200;
--z-skip-link: 2000;
/* Motion */
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 0.15s;
--duration-base: 0.3s;
}
*,
@@ -26,16 +81,85 @@ html,
body {
width: 100%;
height: 100%;
font-family: var(--font-body);
color: var(--text-primary);
margin: 0;
padding: 0;
overflow: hidden;
background: var(--bg-space);
}
/*
* Focus indicators. These used to live in SkillTree.svelte's *scoped* style
* block, so Svelte's scoping meant they never reached NodeModal or StatsPanel
* children — those fell back to the UA default outline, which is low-contrast
* on this background. Global is the only place this rule works.
*/
:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
border-radius: 2px;
}
/*
* Reduced motion. The JS side (see config.prefersReducedMotion) skips the
* entrance and never creates the ambient GSAP loops; this covers the CSS
* animations and transitions those can't reach.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/*
* Visually-hidden but available to screen readers and crawlers. Used for the
* text version of the portfolio (the interactive content is canvas-rendered).
*/
/*
* ...but focusable content inside it must become visible when focused, or a
* sighted keyboard user tabs through ~20 invisible stops with no idea where
* focus is (WCAG 2.4.7). Standard sr-only-focusable treatment.
*/
.sr-only:focus-within {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-skip-link);
width: auto;
height: auto;
max-width: min(90vw, 60rem);
max-height: 90vh;
margin: 0;
overflow: auto;
clip: auto;
white-space: normal;
padding: 1.5rem;
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
border: 1px solid var(--color-gold);
border-radius: 0 0 8px 0;
color: #e8e8f0;
line-height: 1.6;
}
.sr-only:focus-within a {
color: var(--color-gold);
}
.sr-only:focus-within h1,
.sr-only:focus-within h2,
.sr-only:focus-within h3 {
font-family: var(--font-display);
letter-spacing: 0.02em;
color: var(--color-gold);
margin: 1rem 0 0.5rem;
}
.sr-only {
position: absolute;
width: 1px;
@@ -47,3 +171,45 @@ body {
white-space: nowrap;
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 {
font-family: var(--font-display);
letter-spacing: 0.02em;
color: var(--color-gold);
margin: 1.25rem 0 0.5rem;
}
.sr-only.revealed ul {
padding-left: 1.25rem;
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kevin Riehl — Software Engineer (React, Node.js, TypeScript) | Portfolio</title>
<title>Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC) | Portfolio</title>
%sveltekit.head%
</head>
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
// Controls for the allocated path: how many nodes, share, reset.
const {
count = 0,
onReset,
onShare
}: {
count?: number;
onReset: () => void;
onShare: () => Promise<boolean> | boolean;
} = $props();
let copied = $state(false);
async function share() {
copied = await onShare();
if (copied) setTimeout(() => (copied = false), 2000);
}
</script>
{#if count > 0}
<div class="build-bar" role="status" aria-live="polite">
<span class="count"><strong>{count}</strong> allocated</span>
<button onclick={share}>{copied ? '✓ Link copied' : 'Share build'}</button>
<button class="reset" onclick={onReset}>Reset</button>
</div>
{/if}
<style>
.build-bar {
position: fixed;
top: var(--space-5);
right: var(--space-5);
z-index: var(--z-panel-toggle);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.85);
font-size: 0.78rem;
}
.count {
color: var(--text-muted);
}
.count strong {
color: var(--color-gold);
font-variant-numeric: tabular-nums;
}
button {
padding: 0.25rem 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
}
button:hover {
border-color: var(--color-gold);
}
.reset {
border-color: transparent;
color: var(--text-subtle);
}
@media (max-width: 767px) {
.build-bar {
top: auto;
bottom: calc(var(--space-5) + 3.2rem);
right: var(--space-2);
}
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
// First-run interaction hint. The tree gives no affordance that it can be
// dragged, zoomed or clicked — the icons look decorative. Shown once, then
// remembered, so it never nags a returning visitor.
import { fade } from 'svelte/transition';
import { prefersReducedMotion } from '$lib/config';
const { visible = false, onDismiss }: { visible?: boolean; onDismiss?: () => void } = $props();
const duration = prefersReducedMotion() ? 0 : 400;
</script>
{#if visible}
<div class="coach" transition:fade={{ duration }} role="status">
<span>Drag to explore</span>
<span class="sep" aria-hidden="true">·</span>
<span>Scroll to zoom</span>
<span class="sep" aria-hidden="true">·</span>
<span>Click a node for details</span>
<button onclick={() => onDismiss?.()} aria-label="Dismiss hint">✕</button>
</div>
{/if}
<style>
.coach {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid rgba(255, 215, 0, 0.35);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.88);
color: var(--text-muted);
font-size: 0.8rem;
white-space: nowrap;
}
.sep {
color: var(--color-gold);
}
.coach button {
margin-left: var(--space-1);
padding: 0 0.25rem;
border: 0;
background: none;
color: var(--text-subtle);
font: inherit;
cursor: pointer;
}
.coach button:hover {
color: var(--color-gold);
}
@media (max-width: 767px) {
.coach {
font-size: 0.7rem;
gap: var(--space-1);
padding: var(--space-2);
}
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
// Always-visible identity.
//
// The measured problem this solves: on a cold load the site showed a black
// screen for ~5s, then a wordless constellation of icons. At 9 seconds the
// number of words on screen was zero — no name, no title, no indication of
// what any of it was. A recruiter could not learn a single fact without
// clicking. This puts the 5-second pitch on screen immediately and keeps it
// there, sourced from the same origin node as everything else.
import { treeNodes, type OriginNode } from '$lib/data';
const {
showSkip = false,
onSkip,
onStartJourney
}: {
/** True while the intro is still playing. */
showSkip?: boolean;
onSkip?: () => void;
onStartJourney?: () => void;
} = $props();
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
</script>
{#if origin}
<div class="hud">
<p class="hud-name">{origin.fullName}</p>
<p class="hud-title">{origin.jobTitle}</p>
{#if origin.tagline}
<p class="hud-tagline">{origin.tagline}</p>
{/if}
{#if onStartJourney && !showSkip}
<!-- Discoverable entry point; 'J' does the same thing. -->
<button class="journey-cta" onclick={() => onStartJourney()}>
▶ Take the 60-second tour
</button>
{/if}
</div>
{/if}
{#if showSkip}
<button class="skip-intro" onclick={() => onSkip?.()}>Skip intro</button>
{/if}
<style>
.journey-cta {
margin-top: var(--space-3);
padding: 0.3rem 0.7rem;
border: 1px solid rgba(255, 215, 0, 0.5);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.7);
color: var(--color-gold);
font: inherit;
font-size: 0.75rem;
cursor: pointer;
pointer-events: auto;
transition: background var(--duration-fast) var(--ease-out);
}
.journey-cta:hover {
background: rgba(255, 215, 0, 0.15);
}
.hud {
position: fixed;
top: var(--space-5);
left: calc(var(--space-5) + 68px); /* clears the stats toggle */
z-index: var(--z-welcome);
max-width: min(30rem, calc(100vw - 8rem));
pointer-events: none;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.hud-name {
margin: 0;
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.1;
color: var(--color-gold);
}
.hud-title {
margin: 0.15rem 0 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.hud-tagline {
margin: 0.2rem 0 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--text-muted);
}
.skip-intro {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gold);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.85);
color: var(--color-gold);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
}
.skip-intro:hover {
background: rgba(255, 215, 0, 0.15);
}
@media (max-width: 767px) {
.hud {
top: auto;
bottom: var(--space-5);
left: var(--space-4);
right: var(--space-4);
max-width: none;
}
.hud-name {
font-size: 1.1rem;
}
.hud-tagline {
display: none; /* too little room to be worth the noise */
}
}
</style>
+286
View File
@@ -0,0 +1,286 @@
<script lang="ts">
// Guided career narrative — the tree tells its own story.
//
// Browsing a skill tree is self-directed: a visitor has to know what to click
// and in what order, and most won't. This flies the camera along the
// experience arc oldest-first, surfacing one or two highlights per stop, so
// the career reads as progression rather than a pile of nodes.
//
// Deep-linkable (?tour=peraton) so a specific role can be shared directly.
import { getJourneyStops, type JourneyStop } from '$lib/utils/journey';
import { prefersReducedMotion } from '$lib/config';
const {
active = false,
startIndex = 0,
onExit,
onStop
}: {
active?: boolean;
/** Stop to open on — set by the ?tour= deep link. */
startIndex?: number;
onExit: () => void;
/** Move the camera to this stop. */
onStop: (stop: JourneyStop) => void;
} = $props();
const stops = getJourneyStops();
/** Auto-advance dwell per stop. Long enough to read two highlights. */
const DWELL_MS = 9000;
let index = $state(0);
// Reduced motion starts paused: auto-advancing content that moves the camera
// is exactly what that preference is asking us not to do. The tour is still
// fully available, just driven by the user.
let paused = $state(prefersReducedMotion());
let panelEl = $state<HTMLElement | null>(null);
const stop = $derived(stops[index]);
const isLast = $derived(index === stops.length - 1);
function go(next: number) {
if (next < 0 || next >= stops.length) return;
index = next;
}
function exit() {
onExit();
}
// Open on the requested stop each time the journey starts.
$effect(() => {
if (!active) return;
index = Math.max(0, Math.min(startIndex, stops.length - 1));
paused = prefersReducedMotion();
});
// Drive the camera whenever the active stop changes.
$effect(() => {
if (!active || !stop) return;
onStop(stop);
});
// Auto-advance. Reduced motion still advances — the objection is to motion,
// not to pacing — but the camera jumps rather than flies (see panToNode).
$effect(() => {
if (!active || paused || isLast) return;
const current = index;
const timer = setTimeout(() => {
if (current === index) go(current + 1);
}, DWELL_MS);
return () => clearTimeout(timer);
});
// Move focus into the panel so keys land here and screen readers follow.
$effect(() => {
if (!active || !panelEl) return;
const previous = document.activeElement as HTMLElement | null;
panelEl.focus({ preventScroll: true });
return () => previous?.focus?.({ preventScroll: true });
});
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
go(index + 1);
break;
case 'ArrowLeft':
e.preventDefault();
go(index - 1);
break;
case ' ':
e.preventDefault();
paused = !paused;
break;
case 'Escape':
e.preventDefault();
exit();
break;
}
}
</script>
{#if active && stop}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section
bind:this={panelEl}
class="journey"
tabindex="-1"
onkeydown={handleKeydown}
aria-label="Career journey"
>
<div class="progress" role="group" aria-label="Progress">
{#each stops as s, i (s.slug)}
<button
class="pip"
class:done={i <= index}
onclick={() => go(i)}
aria-label="Go to {s.title}"
aria-current={i === index ? 'step' : undefined}
></button>
{/each}
</div>
<!-- aria-live so each stop is announced as the tour advances -->
<div class="body" aria-live="polite" aria-atomic="true">
<p class="kicker">{stop.kicker}</p>
<h2 class="title">{stop.title}</h2>
{#if stop.subtitle}<p class="subtitle">{stop.subtitle}</p>{/if}
{#if stop.points.length}
<ul>
{#each stop.points as point (point)}
<li>{point}</li>
{/each}
</ul>
{/if}
</div>
<div class="controls">
<span class="counter">{index + 1} / {stops.length}</span>
<button onclick={() => go(index - 1)} disabled={index === 0}>‹ Prev</button>
{#if !isLast}
<button class="primary" onclick={() => (paused = !paused)}>
{paused ? '▶ Play' : '❚❚ Pause'}
</button>
{/if}
<button onclick={() => go(index + 1)} disabled={isLast}>Next ›</button>
<button class="exit" onclick={exit}>{isLast ? 'Explore the tree' : 'Exit'}</button>
</div>
</section>
{/if}
<style>
.journey {
position: fixed;
right: var(--space-5);
bottom: var(--space-5);
left: var(--space-5);
z-index: var(--z-toast);
max-width: 34rem;
margin: 0 auto;
padding: var(--space-4);
border: 1px solid var(--color-gold);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, rgba(26, 26, 46, 0.97) 0%, rgba(15, 15, 26, 0.97) 100%);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.7);
}
.journey:focus {
outline: none;
}
.progress {
display: flex;
gap: 4px;
margin-bottom: var(--space-3);
}
.pip {
flex: 1 1 auto;
height: 3px;
padding: 0;
border: 0;
border-radius: 2px;
background: rgba(255, 255, 255, 0.18);
cursor: pointer;
transition: background var(--duration-base) var(--ease-out);
}
.pip.done {
background: var(--color-gold);
}
.kicker {
margin: 0;
color: var(--text-subtle);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin: 0.15rem 0 0;
font-family: var(--font-display);
font-size: 1.3rem;
letter-spacing: 0.02em;
color: var(--color-gold);
}
.subtitle {
margin: 0.1rem 0 0;
color: var(--text-primary);
font-size: 0.88rem;
font-weight: 600;
}
ul {
margin: var(--space-3) 0 0;
padding-left: 1.1rem;
color: var(--text-muted);
font-size: 0.83rem;
line-height: 1.5;
}
li + li {
margin-top: var(--space-2);
}
.controls {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-4);
}
.counter {
margin-right: auto;
color: var(--text-subtle);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.controls button {
padding: 0.35rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.35);
color: var(--text-primary);
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.controls button:hover:not(:disabled) {
border-color: var(--color-gold);
}
.controls button:disabled {
opacity: 0.35;
cursor: default;
}
.controls .primary {
border-color: var(--color-gold);
color: var(--color-gold);
}
.controls .exit {
border-color: transparent;
color: var(--text-subtle);
}
@media (max-width: 767px) {
.journey {
right: var(--space-2);
bottom: var(--space-2);
left: var(--space-2);
padding: var(--space-3);
}
.controls {
flex-wrap: wrap;
}
}
</style>
+2 -2
View File
@@ -11,12 +11,12 @@
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 1000;
z-index: var(--z-modal);
color: var(--color-gold);
}
.loading-indicator p {
font-family: 'Cinzel Decorative', 'Trajan Pro', serif;
font-family: var(--font-display);
font-size: 1.2rem;
margin-top: 1rem;
color: var(--color-gold);
+138 -18
View File
@@ -2,32 +2,106 @@
import type { TreeNode } from '$lib/data';
import { categoryColorHex } from '$lib/theme';
const { node, visible, onClose }: { node: TreeNode; visible: boolean; onClose: () => void } =
$props();
const {
node,
visible,
onClose,
onDismiss,
allocated = false,
onToggleAllocate
}: {
allocated?: boolean;
/** Allocate/remove this node and its path. */
onToggleAllocate?: (id: string) => void;
node: TreeNode;
visible: boolean;
/** Explicit close (✕ button) — always immediate. */
onClose: () => void;
/** Outside/backdrop dismissal — subject to the mobile touch-bleed guard. */
onDismiss: () => void;
} = $props();
let dialogEl = $state<HTMLDivElement | null>(null);
const FOCUSABLE =
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
/**
* Move focus into the dialog on open and restore it on close.
*
* Without this, `aria-modal="true"` was lying: it tells assistive tech that
* everything outside the dialog does not exist, while focus stayed on the
* canvas behind it and Tab walked the whole hidden text version underneath.
*/
$effect(() => {
if (!visible || !dialogEl) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const first = dialogEl.querySelector<HTMLElement>(FOCUSABLE);
(first ?? dialogEl).focus({ preventScroll: true });
return () => previouslyFocused?.focus?.({ preventScroll: true });
});
/** Keep Tab inside the dialog while it's open. */
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab' || !dialogEl) return;
const items = [...dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === dialogEl)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
</script>
<div
class="modal-backdrop"
class:visible
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
aria-label="Modal backdrop. Click to close or press Escape."
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!--
Backdrop is presentational: click-to-close only. It previously carried an
onkeydown Escape handler, but a role="presentation" div with no tabindex can
never be a keydown target, so that handler was unreachable. Escape is handled
by the global key listener (see pixi/interaction.ts), and Tab is trapped by
the dialog below.
-->
<div class="modal-backdrop" class:visible onclick={onDismiss} role="presentation">
<div
bind:this={dialogEl}
class="modal"
class:visible
onclick={(e) => e.stopPropagation()}
onkeydown={trapFocus}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
tabindex="-1"
style="--node-color: {categoryColorHex[node.category]}"
>
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
{#if onToggleAllocate}
<!-- Allocation lives on a button rather than the node click, which
already opens this dialog. -->
<button
class="allocate-btn"
class:on={allocated}
onclick={() => onToggleAllocate(node.id)}
aria-pressed={allocated}
>
{allocated ? '✓ Allocated' : '⊕ Allocate path'}
</button>
{/if}
<h2 id="modal-title">
{#if node.icon}
<img src={node.icon} alt="" class="modal-icon" />
@@ -143,10 +217,15 @@
</div>
{/if}
<!-- Project status badge -->
{#if node.category === 'projects' && node.status}
<!-- Project status badge + third-party usage -->
{#if node.category === 'projects' && (node.status || node.downloads)}
<div class="project-status">
{#if node.status}
<span class="status-badge {node.status}">{node.status}</span>
{/if}
{#if node.downloads}
<span class="downloads-badge">↓ {node.downloads}</span>
{/if}
</div>
{/if}
@@ -202,7 +281,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal);
transition: background 0.2s ease;
backdrop-filter: blur(0px);
padding: 1rem;
@@ -214,6 +293,33 @@
backdrop-filter: blur(4px);
}
.allocate-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: var(--space-3);
padding: 0.3rem 0.75rem;
border: 1px solid rgba(255, 215, 0, 0.45);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-gold);
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.allocate-btn:hover {
background: rgba(255, 215, 0, 0.12);
}
.allocate-btn.on {
background: rgba(255, 215, 0, 0.18);
}
.modal:focus {
outline: none;
}
.modal {
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
border: 1px solid var(--node-color);
@@ -239,6 +345,8 @@
}
.modal h2 {
font-family: var(--font-display);
letter-spacing: 0.02em;
color: var(--node-color);
margin: 0 0 0.5rem 0;
font-size: 1.3rem;
@@ -284,7 +392,7 @@
right: 0.75rem;
background: none;
border: none;
color: #666;
color: var(--text-subtle);
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
@@ -308,7 +416,7 @@
}
.origin-location {
color: #888;
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
@@ -360,7 +468,7 @@
.meta-info .dates {
display: block;
color: #888;
color: var(--text-muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
@@ -471,6 +579,18 @@
transform: translateY(0);
}
.downloads-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border: 1px solid rgba(255, 215, 0, 0.35);
border-radius: 12px;
background: rgba(255, 215, 0, 0.08);
color: var(--color-gold);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.project-status {
margin: 0.75rem 0;
}
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
// Cmd/Ctrl-K search over the whole tree.
//
// The canvas is 57 wordless icons, so there was no way to answer "does he
// know NestJS?" short of clicking around. This makes the data directly
// queryable and flies the camera to whatever you pick.
//
// Built to the ARIA combobox pattern: the input keeps focus and owns
// aria-activedescendant, the list is a listbox, and nothing else steals
// focus — so arrow keys read correctly to a screen reader.
import { searchNodes, type SearchResult } from '$lib/utils/search';
import { categoryColorHex } from '$lib/theme';
import type { TreeNode } from '$lib/data';
import { prefersReducedMotion } from '$lib/config';
const {
open = false,
onClose,
onSelect
}: {
open?: boolean;
onClose: () => void;
onSelect: (node: TreeNode) => void;
} = $props();
let query = $state('');
let activeIndex = $state(0);
let inputEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
const results = $derived<SearchResult[]>(searchNodes(query).slice(0, 8));
// Reset and focus each time it opens.
$effect(() => {
if (!open) return;
query = '';
activeIndex = 0;
const previouslyFocused = document.activeElement as HTMLElement | null;
inputEl?.focus();
return () => previouslyFocused?.focus?.({ preventScroll: true });
});
$effect(() => {
// Keep the active option scrolled into view as arrows move it.
if (!open || !listEl) return;
const el = listEl.children[activeIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
});
function choose(index: number) {
const hit = results[index];
if (!hit) return;
onSelect(hit.node);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
activeIndex = results.length ? (activeIndex + 1) % results.length : 0;
break;
case 'ArrowUp':
e.preventDefault();
activeIndex = results.length ? (activeIndex - 1 + results.length) % results.length : 0;
break;
case 'Enter':
e.preventDefault();
choose(activeIndex);
break;
case 'Escape':
e.preventDefault();
onClose();
break;
case 'Tab':
// Nothing else in here is focusable; keep focus on the input.
e.preventDefault();
break;
}
}
</script>
{#if open}
<!-- Presentational scrim; Escape and the input own all keyboard handling. -->
<div class="palette-backdrop" onclick={onClose} role="presentation"></div>
<div class="palette" role="dialog" aria-modal="true" aria-label="Search the skill tree">
<input
bind:this={inputEl}
bind:value={query}
onkeydown={handleKeydown}
oninput={() => (activeIndex = 0)}
type="text"
class="palette-input"
placeholder="Search skills, projects, companies…"
role="combobox"
aria-expanded={results.length > 0}
aria-controls="palette-results"
aria-autocomplete="list"
aria-activedescendant={results.length ? `palette-opt-${activeIndex}` : undefined}
autocomplete="off"
spellcheck="false"
/>
{#if query && !results.length}
<p class="palette-empty">No matches for “{query}”.</p>
{/if}
<ul bind:this={listEl} id="palette-results" role="listbox" aria-label="Search results">
{#each results as result, i (result.node.id)}
<!--
No keyboard handler here on purpose. In the ARIA combobox pattern the
input keeps focus and drives selection via aria-activedescendant;
making options focusable would break that for screen readers. Mouse
users get click, keyboard users get the arrow keys on the input.
-->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
id="palette-opt-{i}"
role="option"
aria-selected={i === activeIndex}
class:active={i === activeIndex}
style="--node-color: {categoryColorHex[result.node.category]}"
onclick={() => choose(i)}
onmousemove={() => (activeIndex = i)}
>
<span class="dot" aria-hidden="true"></span>
<span class="label">{result.node.label}</span>
{#if result.context}
<span class="context">{result.context}</span>
{/if}
<span class="category">{result.node.category}</span>
</li>
{/each}
</ul>
<p class="palette-hint">
<kbd>↑</kbd><kbd>↓</kbd> to navigate · <kbd>Enter</kbd> to open · <kbd>Esc</kbd> to close
</p>
</div>
{/if}
<style>
.palette-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.palette {
position: fixed;
top: 12vh;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-modal);
width: min(36rem, 92vw);
padding: var(--space-3);
border: 1px solid var(--color-gold);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
}
.palette-input {
width: 100%;
padding: var(--space-3);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.35);
color: var(--text-primary);
font: inherit;
font-size: 1rem;
}
.palette-input::placeholder {
color: var(--text-subtle);
}
.palette-empty {
margin: var(--space-3) var(--space-2) 0;
color: var(--text-muted);
font-size: 0.85rem;
}
ul {
max-height: 46vh;
margin: var(--space-2) 0 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
li {
display: flex;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
li.active {
background: rgba(255, 215, 0, 0.12);
}
.dot {
flex: 0 0 auto;
align-self: center;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-color);
}
.label {
flex: 0 0 auto;
color: var(--text-primary);
font-weight: 600;
font-size: 0.9rem;
}
.context {
flex: 1 1 auto;
overflow: hidden;
color: var(--text-muted);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.category {
flex: 0 0 auto;
margin-left: auto;
color: var(--text-subtle);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.palette-hint {
margin: var(--space-3) var(--space-2) var(--space-1);
color: var(--text-subtle);
font-size: 0.72rem;
}
kbd {
padding: 0.1rem 0.3rem;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 4px;
background: rgba(0, 0, 0, 0.4);
font-family: inherit;
font-size: 0.7rem;
}
@media (max-width: 767px) {
.palette {
top: 4vh;
}
.context {
display: none;
}
}
</style>
+8 -1
View File
@@ -14,6 +14,10 @@
} 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.
@@ -34,7 +38,7 @@
</script>
{#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>
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
{#if origin.location}<p>Location: {origin.location}</p>{/if}
@@ -75,6 +79,9 @@
{#if project.techStack?.length}
<p>Tech: {project.techStack.join(', ')}</p>
{/if}
{#if project.downloads}
<p>{project.downloads}</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}
+382 -58
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { Application, Container } from 'pixi.js';
import type { TreeNode } from '$lib/data';
import { categoryColorHex } from '$lib/theme';
@@ -8,7 +9,16 @@
import Tooltip from './Tooltip.svelte';
import NodeModal from './NodeModal.svelte';
import StatsPanel from './StatsPanel.svelte';
import { MOBILE_BREAKPOINT } from '$lib/config';
import TreeNavigator from './TreeNavigator.svelte';
import Hud from './Hud.svelte';
import CoachHint from './CoachHint.svelte';
import SearchPalette from './SearchPalette.svelte';
import JourneyMode from './JourneyMode.svelte';
import BuildBar from './BuildBar.svelte';
import { allocate, deallocate, encodeBuild, decodeBuild } from '$lib/utils/allocation';
import { updateAllocation } from '$lib/pixi/allocation';
import { getJourneyStops, findStopIndex } from '$lib/utils/journey';
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
import {
BRANCH_IDS,
calculatePlayerLevel,
@@ -17,16 +27,96 @@
countByProficiency
} from '$lib/utils/stats';
import { createBackground } from '$lib/pixi/background';
import { createController } from '$lib/pixi/controller';
import { createController, type SkillTreeController } from '$lib/pixi/controller';
import { createLines } from '$lib/pixi/lines';
import { createNodes } from '$lib/pixi/nodes';
import { attachPanZoom, attachKeyboard } from '$lib/pixi/interaction';
import {
attachPanZoom,
attachKeyboard,
updateParallax,
panToNode,
updateLabels
} from '$lib/pixi/interaction';
import { runEntranceAnimations } from '$lib/pixi/animation';
import { createLifecycle } from '$lib/pixi/lifecycle';
const { onRenderFail }: { onRenderFail?: () => void } = $props();
let container: HTMLDivElement;
// Set once the canvas is up; the tree navigator uses it to pan the camera.
let controllerRef: SkillTreeController | null = null;
let selectedNode = $state<TreeNode | null>(null);
let modalVisible = $state(false);
let showWelcomeTitle = $state(true);
// Intro state. A returning visitor should land on the settled tree rather
// than sit through the entrance again.
const VISITED_KEY = 'atlas:visited';
let introPlaying = $state(false);
let showCoachHint = $state(false);
let searchOpen = $state(false);
// Guided career narrative. Deep-linkable via ?tour=peraton.
let journeyActive = $state(false);
let journeyStartIndex = $state(0);
// Allocated path. SvelteSet so mutation is tracked — a plain Set is not
// proxied by $state, the same trap that silently broke the stats panel.
const allocated = new SvelteSet<string>();
function toggleAllocate(id: string) {
const next = allocated.has(id) ? deallocate(allocated, id) : allocate(allocated, id);
allocated.clear();
for (const nodeId of next) allocated.add(nodeId);
}
function resetBuild() {
allocated.clear();
}
async function shareBuild(): Promise<boolean> {
const url = new URL(window.location.href);
url.searchParams.set('build', encodeBuild(allocated));
try {
await navigator.clipboard.writeText(url.toString());
return true;
} catch {
// Clipboard can be blocked (insecure origin, permissions). Put the link
// in the address bar so it is still copyable by hand.
history.replaceState(null, '', url);
return false;
}
}
function startJourney(index = 0) {
journeyStartIndex = index;
journeyActive = true;
searchOpen = false;
showCoachHint = false;
closeModal();
}
let skipEntrance: (() => void) | null = null;
function hasVisitedBefore() {
try {
return localStorage.getItem(VISITED_KEY) === '1';
} catch {
return false; // private mode / storage disabled
}
}
function markVisited() {
try {
localStorage.setItem(VISITED_KEY, '1');
} catch {
/* not important enough to surface */
}
}
function skipIntro() {
skipEntrance?.();
introPlaying = false;
showWelcomeTitle = false;
markVisited();
}
// Tooltip state
let hoveredNode = $state<TreeNode | null>(null);
@@ -45,7 +135,9 @@
// Stats panel state
let statsPanelVisible = $state(false);
let expandedBranches = $state(new Set<string>(BRANCH_IDS)); // All expanded by default
// 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.
const expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
function openModal(node: TreeNode) {
selectedNode = node;
@@ -56,18 +148,23 @@
}
function closeModal() {
// Prevent closing if modal just opened (within 300ms)
// This fixes touch events "bleeding through" on mobile
if (Date.now() - modalOpenedAt < 300) {
return;
}
modalVisible = false;
setTimeout(() => {
selectedNode = null;
}, 200);
}
/**
* Backdrop//outside dismissal only. The 300ms guard exists because a tap that
* opens the modal on mobile can bleed through to the backdrop underneath —
* but it used to gate *every* close path, so pressing Escape or the ✕ right
* after opening silently did nothing.
*/
function dismissModal() {
if (Date.now() - modalOpenedAt < 300) return;
closeModal();
}
function toggleStatsPanel() {
statsPanelVisible = !statsPanelVisible;
if (statsPanelVisible && modalVisible) {
@@ -81,7 +178,6 @@
} else {
expandedBranches.add(branchId);
}
expandedBranches = expandedBranches; // Trigger reactivity
}
// Stats calculations (reactive)
@@ -98,30 +194,54 @@
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 () => {
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();
try {
await app.init({
background: '#05050a',
resizeTo: container,
antialias: true
// The scene is soft glow circles — MSAA costs fill rate and buys almost
// nothing here. Sharpness comes from rendering at device resolution instead.
antialias: false,
resolution: Math.min(window.devicePixelRatio || 1, 2),
autoDensity: true,
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.
// eslint-disable-next-line svelte/no-dom-manipulating
container.appendChild(app.canvas);
// Hide loading indicator - PixiJS is ready, welcome title will be LCP
isLoading = false;
// Background layers (gradient → nebula → stars → dust → vignette)
const bg = createBackground(app, isMobile);
// Background layers (gradient → nebula → stars → dust → vignette).
// 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
const treeContainer = new Container();
@@ -136,6 +256,8 @@
treeContainer.alpha = 1;
const controller = createController(app, treeContainer, bg, endScale);
controllerRef = controller;
life.add(() => (controllerRef = null));
// Build the visualisation (connections first, then nodes on top)
controller.lineObjects = createLines(treeContainer);
@@ -143,66 +265,214 @@
isMobile,
isTouchDevice,
controller,
life,
onOpen: openModal,
onHover: (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
// genuinely ready. (This used to fire right after app.init(), so the spinner
// resolved onto an empty screen.)
isLoading = false;
// Pan / zoom / parallax + keyboard navigation
const detachPanZoom = attachPanZoom(controller, {
const panZoom = attachPanZoom(
controller,
{
onTooltipMove: (x, y) => {
tooltipX = x;
tooltipY = y;
}
});
const detachKeyboard = attachKeyboard(controller, {
},
hasHover: () => hoveredNode !== null
},
life
);
attachKeyboard(
controller,
{
onToggleStats: toggleStatsPanel,
onCloseModal: closeModal,
isModalVisible: () => modalVisible,
isStatsVisible: () => statsPanelVisible
isStatsVisible: () => statsPanelVisible,
onOpenSearch: () => (searchOpen = true),
isSearchOpen: () => searchOpen,
onStartJourney: () => startJourney(0),
isJourneyActive: () => journeyActive
},
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
runEntranceAnimations(controller, {
// Labels start hidden below the zoom threshold; set their initial state
// so they're correct before the first zoom interaction.
updateLabels(controller);
// Sequenced entrance animation. Returning visitors skip straight to the
// settled tree; first-timers get the intro plus a Skip control.
const returning = hasVisitedBefore();
introPlaying = !returning && !prefersReducedMotion();
const entrance = runEntranceAnimations(controller, {
isMobile,
life,
skipIntro: returning,
onTitleDone: () => {
showWelcomeTitle = false;
}
});
skipEntrance = entrance.skip;
// Store cleanup functions
cleanupFunctions.push(() => {
window.removeEventListener('resize', handleResize);
detachPanZoom();
detachKeyboard();
});
// Show the interaction hint once the tree is actually there to interact
// with — timed off the real entrance length, not a guess, so "Click a
// node" never appears before the nodes do and Skip never disappears
// while the intro is still running.
if (!returning) {
const hintTimer = setTimeout(() => {
introPlaying = false;
showCoachHint = true;
// Only now has the visitor actually seen the intro. Marking at
// mount instead would flag someone who bounced after a second as
// having seen it, and they'd never get it.
markVisited();
}, entrance.durationMs + 300);
life.add(() => clearTimeout(hintTimer));
}
// Deep links, applied immediately and regardless of whether this is a
// first or return visit. These were previously nested inside the
// first-visit hint timer, so a shared ?build= or ?tour= link did nothing
// for a returning visitor and waited out the whole intro for everyone
// else — backwards, since a shared link is exactly the cold-arrival case.
const params = new URLSearchParams(window.location.search);
// ?build=... restores a shared allocation.
const shared = decodeBuild(params.get('build'));
if (shared.size) {
for (const id of shared) allocated.add(id);
updateAllocation(controller, allocated);
// The entrance tweens every node's alpha to 1, which lands after this
// and wipes the dimming. Re-apply once it has finished, or the shared
// build shows a full-brightness tree with a counter that disagrees.
const repaint = setTimeout(
() => updateAllocation(controller, allocated),
entrance.durationMs + 100
);
life.add(() => clearTimeout(repaint));
}
// ?tour=peraton deep-links straight into that stop of the journey.
const tourParam = params.get('tour');
if (tourParam !== null) {
const stops = getJourneyStops();
const found = findStopIndex(stops, tourParam);
// A bare ?tour starts from the beginning.
startJourney(found === -1 ? 0 : found);
// Don't make someone who followed a tour link sit through the intro.
skipIntro();
}
} catch (err) {
// No WebGL, or a texture failed to load. Don't strand the user on a
// spinner that never resolves — reveal the text version instead.
console.error('Skill tree failed to initialise:', err);
isLoading = false;
// Hands off to +page.svelte, which reveals the text version.
onRenderFail?.();
life.dispose();
app.destroy(true, { children: true, texture: true });
}
});
// Cleanup on component unmount
$effect(() => {
return () => {
cleanupFunctions.forEach((fn) => fn());
};
return () => life.dispose();
});
// Calculate tooltip position to keep it on screen
// Repaint allocation styling whenever the build changes.
$effect(() => {
// Touch .size so this re-runs on every mutation of the SvelteSet.
void allocated.size;
if (controllerRef) updateAllocation(controllerRef, allocated);
});
// 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(() => {
let x = tooltipX + 15;
let y = tooltipY + 15;
const gap = 15;
const rect = tooltipEl?.getBoundingClientRect();
const width = rect?.width || 280;
const height = rect?.height || 120;
// Keep tooltip on screen
const tooltipWidth = 280;
const tooltipHeight = 120;
let x = tooltipX + gap;
let y = tooltipY + gap;
if (x + tooltipWidth > windowWidth) {
x = tooltipX - tooltipWidth - 15;
}
if (y + tooltipHeight > windowHeight) {
y = tooltipY - tooltipHeight - 15;
}
// Flip to the other side of the cursor rather than overflow the viewport.
if (x + width > windowWidth) x = Math.max(0, tooltipX - width - gap);
if (y + height > windowHeight) y = Math.max(0, tooltipY - height - gap);
return `left: ${x}px; top: ${y}px;`;
return `transform: translate3d(${x}px, ${y}px, 0);`;
});
</script>
@@ -212,16 +482,66 @@
<div
bind:this={container}
class="canvas-container"
role="application"
aria-label="Interactive skill tree visualization. Use arrow keys to pan, +/- to zoom, R to reset view, C to toggle character stats panel."
role="img"
aria-label="Skill tree visualisation. Arrow keys pan, plus and minus zoom, R resets the view, C toggles the character stats panel. For a navigable list of every entry, use the skill tree navigator."
tabindex="0"
></div>
<!--
The keyboard/AT interface for the canvas. role="application" used to sit on
the div above, which tells screen readers to suppress their own navigation
and forward every key — appropriate only for a widget implementing a full
keyboard model, which the canvas does not. This does.
-->
<TreeNavigator
onActivate={openModal}
onFocusNode={(node) => {
if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
}}
/>
<!-- Loading Indicator for Lighthouse LCP -->
{#if isLoading}
<LoadingIndicator />
{/if}
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
<Hud showSkip={introPlaying} onSkip={skipIntro} onStartJourney={() => startJourney(0)} />
<BuildBar count={allocated.size} onReset={resetBuild} onShare={shareBuild} />
<JourneyMode
active={journeyActive}
startIndex={journeyStartIndex}
onExit={() => (journeyActive = false)}
onStop={(stop) => {
if (controllerRef) {
panToNode(controllerRef, stop.node.x, stop.node.y, life, {
scale: stop.scale,
duration: 1.1
});
}
}}
/>
<SearchPalette
open={searchOpen}
onClose={() => (searchOpen = false)}
onSelect={(node) => {
if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
openModal(node);
}}
/>
<!-- The hint lives in the same bottom-centre slot as the journey card, so it
must yield while the tour is running. -->
<CoachHint
visible={showCoachHint && !journeyActive}
onDismiss={() => {
showCoachHint = false;
}}
/>
<!-- Welcome Title Card -->
{#if showWelcomeTitle}
<WelcomeTitle />
@@ -230,6 +550,7 @@
<!-- Hover Tooltip (hidden on mobile) -->
{#if hoveredNode && !modalVisible && !isMobile}
<Tooltip
bind:element={tooltipEl}
node={hoveredNode}
style={tooltipStyle}
colorHex={categoryColorHex[hoveredNode.category]}
@@ -238,7 +559,14 @@
<!-- Modal Overlay -->
{#if selectedNode}
<NodeModal node={selectedNode} visible={modalVisible} onClose={closeModal} />
<NodeModal
node={selectedNode}
visible={modalVisible}
onClose={closeModal}
onDismiss={dismissModal}
allocated={allocated.has(selectedNode.id)}
onToggleAllocate={toggleAllocate}
/>
{/if}
<!-- Character Stats Panel (toggle button + backdrop + panel) -->
@@ -255,12 +583,8 @@
/>
<style>
/* Global focus indicators for accessibility */
*:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
/* Focus indicators are global now — see app.css. A scoped rule here could
never reach NodeModal or StatsPanel children. */
.canvas-container {
width: 100%;
height: 100%;
+43 -23
View File
@@ -40,27 +40,36 @@
<!-- Stats Panel Backdrop -->
{#if visible}
<div
class="stats-panel-backdrop visible"
onclick={onToggle}
onkeydown={(e) => e.key === 'Escape' && onToggle()}
role="presentation"
aria-label="Stats panel backdrop. Click to close or press Escape."
></div>
<!-- Presentational click-to-close. The onkeydown this used to carry could
never fire (role="presentation", no tabindex); Escape is global. -->
<div class="stats-panel-backdrop visible" onclick={onToggle} role="presentation"></div>
{/if}
<!-- Stats Panel -->
<!--
`inert` rather than aria-hidden: the panel is only translated off-screen, so
its focusable children stayed in the tab order while announcing as nothing —
a direct axe `aria-hidden-focus` violation. inert removes it from the
accessibility tree AND the tab order together.
-->
<div
id="stats-panel"
class="stats-panel"
class:visible
role="complementary"
aria-label="Character Statistics"
aria-hidden={!visible}
inert={!visible}
>
<!-- Character Card -->
<div class="character-card">
<img src="/headshot.jpg" alt="Character Portrait" class="character-portrait" />
<img
src="/headshot.jpg"
alt=""
width="120"
height="120"
decoding="async"
class="character-portrait"
/>
<div class="character-stats-bar">
<div class="stat-item">
@@ -70,7 +79,7 @@
</div>
</div>
<h1 class="character-name">Kevin Riehl</h1>
<p class="character-name">Kevin Riehl</p>
<p class="character-title">{playerClass}</p>
</div>
@@ -107,13 +116,14 @@
<!-- Skill Branches -->
{#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)}
<div class="skill-branch">
<div
<!-- A real <button>: the previous div+role="button" handled Enter but
not Space, which the ARIA button pattern requires. -->
<button
type="button"
class="branch-header"
onclick={() => onToggleBranch(branchId)}
onkeydown={(e) => e.key === 'Enter' && onToggleBranch(branchId)}
tabindex="0"
role="button"
aria-expanded={expandedBranches.has(branchId)}
aria-controls="skill-list-{branchId}"
>
<img
src={treeNodes.find((n) => n.id === branchId)?.icon || ''}
@@ -123,10 +133,10 @@
<span class="branch-title">{branch.label}</span>
<span class="branch-count">({branch.skills.length})</span>
<span class="expand-icon" class:expanded={expandedBranches.has(branchId)}>▼</span>
</div>
</button>
{#if expandedBranches.has(branchId)}
<div class="skill-list" transition:slide={{ duration: 300 }}>
<div id="skill-list-{branchId}" class="skill-list" transition:slide={{ duration: 300 }}>
{#each branch.skills as skill (skill.id)}
<div class="skill-item">
{#if skill.icon}
@@ -169,7 +179,7 @@
border: 2px solid var(--color-gold);
border-radius: 12px;
cursor: pointer;
z-index: 950;
z-index: var(--z-panel-toggle);
display: flex;
align-items: center;
justify-content: center;
@@ -211,8 +221,10 @@
box-shadow:
4px 0 40px rgba(255, 215, 0, 0.3),
8px 0 80px rgba(255, 215, 0, 0.15);
z-index: 900;
transform: translateX(-100%);
z-index: var(--z-panel);
/* Offset past the box-shadow (8px offset + 80px blur) so the gold glow
doesn't bleed down the left edge of the viewport while closed. */
transform: translateX(calc(-100% - 100px));
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
overflow-x: hidden;
@@ -250,7 +262,7 @@
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 899;
z-index: var(--z-panel-backdrop);
transition: opacity 0.2s ease;
}
@@ -293,6 +305,7 @@
}
.character-name {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: bold;
color: var(--color-gold);
@@ -322,6 +335,8 @@
}
.proficiency-summary h3 {
font-family: var(--font-display);
letter-spacing: 0.04em;
color: var(--color-gold);
font-size: 0.9rem;
margin-bottom: 0.75rem;
@@ -360,6 +375,11 @@
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
/* Reset the UA button defaults so this keeps the previous appearance. */
width: 100%;
text-align: left;
font: inherit;
color: inherit;
}
.branch-header:hover {
@@ -383,12 +403,12 @@
.branch-count {
font-size: 0.8rem;
color: #888;
color: var(--text-muted);
}
.expand-icon {
font-size: 0.8rem;
color: #666;
color: var(--text-subtle);
transition: transform 0.2s ease;
}
@@ -446,7 +466,7 @@
.skill-years {
font-size: 0.7rem;
color: #666;
color: var(--text-subtle);
margin-left: 0.5rem;
}
+36 -4
View File
@@ -1,10 +1,34 @@
<script lang="ts">
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>
<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>
{#if node.icon}
<img src={node.icon} alt="" class="tooltip-icon" />
@@ -18,13 +42,19 @@
<style>
.tooltip {
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%);
border: 1px solid var(--node-color);
border-radius: 8px;
padding: 0.75rem 1rem;
max-width: 280px;
pointer-events: none;
z-index: 500;
z-index: var(--z-tooltip);
box-shadow:
0 0 20px color-mix(in srgb, var(--node-color) 25%, transparent),
0 4px 12px rgba(0, 0, 0, 0.5);
@@ -43,6 +73,8 @@
}
.tooltip h3 {
font-family: var(--font-display);
letter-spacing: 0.02em;
color: var(--node-color);
margin: 0 0 0.35rem 0;
font-size: 0.95rem;
@@ -57,7 +89,7 @@
}
.tooltip .hint {
color: #666;
color: var(--text-subtle);
font-size: 0.7rem;
font-style: italic;
}
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
// Accessible counterpart to the canvas.
//
// The skill tree is drawn entirely in WebGL: nodes are PixiJS Containers with
// no DOM representation, so before this there was no way to reach a node by
// keyboard or have one announced. The canvas advertised role="application",
// which tells screen readers to hand every key to the app — for a widget that
// implemented no keyboard model at all.
//
// This renders the same data.ts as a real WAI-ARIA tree: roving tabindex,
// arrow-key traversal, Enter/Space to open the same modal a click opens.
// Visually hidden until focused, then shown, so it doubles as a skip-target
// for sighted keyboard users instead of ~20 invisible tab stops.
import { treeNodes, type TreeNode } from '$lib/data';
import { buildHierarchy, flattenVisible } from '$lib/utils/tree';
import { categoryColorHex } from '$lib/theme';
import { SvelteSet } from 'svelte/reactivity';
const {
onActivate,
onFocusNode
}: {
/** Enter/Space on a node — opens the same modal as a canvas click. */
onActivate: (node: TreeNode) => void;
/** Focus moved to a node — pans the canvas so the two stay in sync. */
onFocusNode?: (node: TreeNode) => void;
} = $props();
const roots = buildHierarchy(treeNodes);
// SvelteSet, not $state(new Set()) — Svelte does not proxy a plain Set, so
// in-place mutation is untracked. (This is the same trap that silently broke
// the stats panel's expand/collapse.)
const expanded = new SvelteSet<string>(roots.map((r) => r.node.id));
let activeId = $state(roots[0]?.node.id ?? '');
const rows = $derived(flattenVisible(roots, (id) => expanded.has(id)));
function setExpanded(id: string, open: boolean) {
if (open) expanded.add(id);
else expanded.delete(id);
}
function focusRow(index: number) {
const row = rows[index];
if (!row) return;
activeId = row.branch.node.id;
document.getElementById(`tn-${row.branch.node.id}`)?.focus();
onFocusNode?.(row.branch.node);
}
function handleKeydown(e: KeyboardEvent, index: number) {
const { branch } = rows[index];
const hasChildren = branch.children.length > 0;
const isOpen = expanded.has(branch.node.id);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
focusRow(Math.min(index + 1, rows.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case 'ArrowRight':
e.preventDefault();
if (hasChildren && !isOpen) setExpanded(branch.node.id, true);
else if (hasChildren) focusRow(index + 1);
break;
case 'ArrowLeft': {
e.preventDefault();
if (hasChildren && isOpen) {
setExpanded(branch.node.id, false);
break;
}
// Otherwise move to the parent row.
const parentId = branch.node.parentId;
const parentIndex = rows.findIndex((r) => r.branch.node.id === parentId);
if (parentIndex !== -1) focusRow(parentIndex);
break;
}
case 'Home':
e.preventDefault();
focusRow(0);
break;
case 'End':
e.preventDefault();
focusRow(rows.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
onActivate(branch.node);
break;
}
}
</script>
<div class="tree-nav">
<h2 id="tree-nav-label" tabindex="-1">Skill tree navigator</h2>
<p class="tree-nav-hint">
Arrow keys move between entries, Right and Left expand and collapse, Enter opens details.
</p>
<ul role="tree" aria-labelledby="tree-nav-label">
{#each rows as { branch, level }, index (branch.node.id)}
{@const node = branch.node}
{@const hasChildren = branch.children.length > 0}
<li
role="treeitem"
id="tn-{node.id}"
aria-level={level}
aria-expanded={hasChildren ? expanded.has(node.id) : undefined}
aria-selected={activeId === node.id}
tabindex={activeId === node.id ? 0 : -1}
style="--node-color: {categoryColorHex[node.category]}; --level: {level}"
onkeydown={(e) => handleKeydown(e, index)}
onclick={() => onActivate(node)}
onfocus={() => {
activeId = node.id;
onFocusNode?.(node);
}}
>
<span class="tn-dot" aria-hidden="true"></span>
<span class="tn-label">{node.label}</span>
<span class="tn-category">{node.category}</span>
</li>
{/each}
</ul>
</div>
<style>
/*
* Visually hidden until something inside it takes focus. Same idea as a skip
* link: invisible to mouse users, but a sighted keyboard user gets a real,
* visible panel instead of focus vanishing into a 1x1px clip region.
*/
.tree-nav {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.tree-nav:focus-within {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-tree-nav);
width: min(24rem, 92vw);
height: auto;
max-height: 90vh;
margin: 0;
padding: 1rem;
overflow-y: auto;
clip: auto;
white-space: normal;
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
border: 1px solid var(--color-gold);
border-radius: 0 0 10px 0;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.tree-nav h2 {
font-family: var(--font-display);
letter-spacing: 0.03em;
margin: 0 0 0.25rem;
color: var(--color-gold);
font-size: 1rem;
}
.tree-nav-hint {
margin: 0 0 0.75rem;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.4;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
padding-left: calc(0.5rem + (var(--level) - 1) * 0.85rem);
border-radius: 6px;
color: #e8e8f0;
font-size: 0.85rem;
cursor: pointer;
}
li:hover,
li[aria-selected='true'] {
background: rgba(255, 255, 255, 0.06);
}
.tn-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-color);
}
.tn-label {
flex: 1 1 auto;
}
.tn-category {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
+3 -1
View File
@@ -13,12 +13,13 @@
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 100;
z-index: var(--z-welcome);
pointer-events: none;
animation: titleFadeInOut 2.5s ease-in-out forwards;
}
.welcome-title .welcome-name {
font-family: var(--font-display);
font-size: 2.5rem;
font-weight: bold;
color: var(--color-gold);
@@ -35,6 +36,7 @@
color: var(--text-muted);
margin: 0.5rem 0 0 0;
text-transform: uppercase;
font-family: var(--font-display);
letter-spacing: 0.3em;
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, afterEach } from 'vitest';
import { prefersReducedMotion, byDevice } from './config';
const originalWindow = (globalThis as { window?: unknown }).window;
function stubMatchMedia(matches: boolean | null) {
(globalThis as { window?: unknown }).window =
matches === null
? {} // window exists but matchMedia doesn't (very old/embedded engines)
: { matchMedia: (media: string) => ({ matches, media }) };
}
afterEach(() => {
(globalThis as { window?: unknown }).window = originalWindow;
});
describe('prefersReducedMotion', () => {
it('is false when there is no window (SSR / prerender)', () => {
delete (globalThis as { window?: unknown }).window;
expect(prefersReducedMotion()).toBe(false);
});
it('is false when window has no matchMedia', () => {
stubMatchMedia(null);
expect(prefersReducedMotion()).toBe(false);
});
it('is true when the user asked for reduced motion', () => {
stubMatchMedia(true);
expect(prefersReducedMotion()).toBe(true);
});
it('is false when the user has not', () => {
stubMatchMedia(false);
expect(prefersReducedMotion()).toBe(false);
});
it('reads matchMedia at call time, not module load', () => {
stubMatchMedia(false);
expect(prefersReducedMotion()).toBe(false);
stubMatchMedia(true);
expect(prefersReducedMotion()).toBe(true);
});
});
describe('byDevice', () => {
it('picks the mobile value on mobile and the desktop value otherwise', () => {
expect(byDevice({ desktop: 'd', mobile: 'm' }, true)).toBe('m');
expect(byDevice({ desktop: 'd', mobile: 'm' }, false)).toBe('d');
});
});
+29 -3
View File
@@ -38,6 +38,16 @@ export const NODE_RADII: Record<string, number> = {
};
export const MOBILE_RADIUS_FACTOR = 0.8;
/**
* Zoom at which on-canvas node labels appear.
*
* Deliberately just above the settled desktop zoom (endScale = 1), so the
* default full-tree view stays a clean constellation — 57 constant-size labels
* at that zoom overlap into noise. Zooming in at all reveals them, and 'L'
* forces them on at any zoom.
*/
export const LABEL_ZOOM_THRESHOLD = 1.05;
/** Keyboard pan distance per arrow-key press (px). */
export const PAN_SPEED = 50;
@@ -51,11 +61,27 @@ export const PARTICLE_COUNTS = {
/** Entrance-animation timings (seconds), split by device. */
export const TIMINGS = {
titleDuration: { desktop: 2.5, mobile: 1.5 },
zoomDuration: { desktop: 4.5, mobile: 2.5 },
nodeStagger: { desktop: 0.08, mobile: 0.05 }
titleDuration: { desktop: 1.6, mobile: 1.1 },
// Was 4.5s desktop. Nothing is readable until this finishes, and it is the
// dominant term in time-to-first-information.
zoomDuration: { desktop: 2, mobile: 1.5 },
// 0.08 x 56 nodes was 4.5s of cascade on its own, dominating the entrance.
nodeStagger: { desktop: 0.045, mobile: 0.03 }
} as const;
/**
* Whether the visitor has asked for reduced motion.
*
* This site is unusually motion-heavy — a multi-second forced zoom, a staggered
* entrance, ~480 looping tweens and a continuous dust ticker — and none of it
* was previously conditional. Read at call time, not module load, so it is
* correct under SSR/prerender and picks up an OS-level change on reload.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Helper: pick a desktop/mobile value. */
export function byDevice<T>(pair: { desktop: T; mobile: T }, isMobile: boolean): T {
return isMobile ? pair.mobile : pair.desktop;
+24 -15
View File
@@ -15,6 +15,8 @@ export interface OriginNode extends BaseNode {
category: 'origin';
fullName?: string;
jobTitle?: string;
/** One-line hook for the persistent HUD — the 5-second pitch. */
tagline?: string;
aboutMe?: string;
location?: string;
email?: string;
@@ -88,9 +90,10 @@ export const treeNodes: TreeNode[] = [
size: 'x-large',
icon: '',
fullName: 'Kevin Riehl',
jobTitle: 'Software Development - Lead Associate',
jobTitle: 'Lead Software Engineer',
tagline: 'WebRTC telehealth · MediaPipe accessibility · air-gapped CI',
aboutMe:
'Intermediate Software Engineer with 5+ years of experience architecting full-stack applications using React, Node.js (Hapi), and SQL. Expertise includes modernizing legacy codebases, implementing rigorous testing standards, and leading frontend architecture improvements. Passionate about leveraging modern tools like ShadCN and Docker to streamline development workflows.',
"Software engineer with 5+ years building full-stack applications for Defense Health Agency programs. I've architected a WebRTC telehealth platform, shipped real-time gesture recognition that made chat usable for speech-impaired patients, and built the deployment tooling that unblocked a team after an air-gapped infrastructure migration. React, Node.js, NestJS, TypeScript, SQL.",
location: 'Kent, WA',
email: 'kevinriehl@gmail.com',
linkedIn: 'https://www.linkedin.com/in/kevinriehl',
@@ -134,7 +137,7 @@ export const treeNodes: TreeNode[] = [
{
id: 'exp-job1',
label: 'Software Engineer',
description: 'Junior developer at the start of my career.',
description: 'Refactored legacy PHP endpoints onto Hapi.js and ran a MySQL to MSSQL migration.',
x: 420,
y: -280,
parentId: 'exp-contract',
@@ -155,7 +158,7 @@ export const treeNodes: TreeNode[] = [
{
id: 'exp-job2',
label: 'Software Engineer',
description: 'Growing my skills and taking on more responsibility.',
description: 'Built a React + Hapi.js analytics search engine over complex event datasets.',
x: 500,
y: -350,
parentId: 'exp-job1',
@@ -177,7 +180,7 @@ export const treeNodes: TreeNode[] = [
{
id: 'exp-job3',
label: 'Intermediate Software Engineer',
description: 'Stepping into a more senior role.',
description: 'Web Bluetooth biometric monitoring, plus a team-wide move to ShadCN and Docker.',
x: 560,
y: -430,
parentId: 'exp-job2',
@@ -199,7 +202,8 @@ export const treeNodes: TreeNode[] = [
{
id: 'exp-job4',
label: 'Intermediate Software Engineer',
description: 'Where one journey ends, another begins.',
description:
'WebRTC telehealth platform and real-time gesture recognition for speech-impaired users.',
x: 600,
y: -510,
parentId: 'exp-job3',
@@ -221,7 +225,8 @@ export const treeNodes: TreeNode[] = [
{
id: 'exp-job5',
label: 'Lead Associate',
description: 'Where I am now.',
description:
'Air-gapped deployment automation, a NestJS audit interceptor, and 90%+ test coverage.',
x: 620,
y: -590,
parentId: 'exp-job4',
@@ -233,9 +238,9 @@ export const treeNodes: TreeNode[] = [
startDate: 'January 2026',
endDate: 'Present',
highlights: [
'Proactively engineered a suite of automation tools to streamline code deployment workflows to an air-gapped Git instance following a critical infrastructure migration, eliminating manual bottlenecks and restoring team development velocity.',
'Designed and implemented a custom NestJS API interceptor to dynamically detect and audit specific user activities, providing robust, real-time daily activity tracking for compliance and analytics.',
'Spearheaded a rigorous testing initiative to elevate code quality, systematically architecting and executing unit tests to scale platform test coverage from a legacy deficit to a resilient 90%+ baseline.'
'Proactively engineered a suite of automation tools to streamline code deployment workflows to an air-gapped Git instance following a critical infrastructure migration, eliminating manual bottlenecks and restoring team development velocity',
'Designed and implemented a custom NestJS API interceptor to dynamically detect and audit specific user activities, providing robust, real-time daily activity tracking for compliance and analytics',
'Spearheaded a rigorous testing initiative to elevate code quality, systematically architecting and executing unit tests to scale platform test coverage from a legacy deficit to a resilient 90%+ baseline'
]
},
@@ -306,6 +311,8 @@ export const treeNodes: TreeNode[] = [
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
repoUrl: 'https://github.com/TehRiehlDeal/TVDB-API',
pypiUrl: 'https://pypi.org/project/tvdbAPI/',
// Lifetime PyPI downloads, checked 2026-08-09 (pypistats.org).
downloads: '2,277 downloads',
status: 'maintained'
},
{
@@ -322,6 +329,8 @@ export const treeNodes: TreeNode[] = [
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
repoUrl: 'https://github.com/TehRiehlDeal/tmdbAPI',
pypiUrl: 'https://pypi.org/project/tmdbAPI/',
// Lifetime PyPI downloads, checked 2026-08-09 (pypistats.org).
downloads: '784 downloads',
status: 'maintained'
},
{
@@ -366,9 +375,9 @@ export const treeNodes: TreeNode[] = [
category: 'projects',
size: 'small',
icon: '/icons/svelte.svg',
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Tailwind CSS'],
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Vitest'],
projectUrl: 'https://tehriehldeal.com',
repoUrl: 'https://github.com/yourusername/skill-tree-portfolio'
repoUrl: 'https://git.tehriehldeal.com/TehRiehlDeal/skill-tree-portfolio'
},
{
id: 'proj-character-builder',
@@ -629,7 +638,7 @@ export const treeNodes: TreeNode[] = [
proficiency: 'intermediate'
},
{
id: 'tool-vscod',
id: 'tool-vscode',
label: 'VSCode',
description:
'Powerful, lightweight code editor by Microsoft with built-in Git support, debugging, and a vast extension ecosystem.',
@@ -920,8 +929,8 @@ export const treeNodes: TreeNode[] = [
},
{
id: 'edu-degree',
label: 'University',
description: "Bachelor's degree in Computer Science.",
label: 'Green River College',
description: "Bachelor's of Applied Science in IT and Software Development.",
x: -300,
y: 80,
parentId: 'edu-branch',
+40
View File
@@ -0,0 +1,40 @@
// Visual treatment for allocated nodes.
//
// The path reads as "lit" by dimming everything else rather than brightening
// the selection — the tree is already near its brightness ceiling, so
// recession is the only headroom available.
import { isEdgeAllocated } from '$lib/utils/allocation';
import type { SkillTreeController } from './controller';
/** Alpha for nodes outside the current build. */
const DIMMED_NODE_ALPHA = 0.22;
/** Tint applied to connections outside the current build. */
const DIMMED_LINE_TINT = 0x2a2a35;
/**
* Apply (or clear) allocation styling.
*
* Nodes are dimmed via `alpha`, which nothing tweens after the entrance
* finishes. Connections are dimmed via `tint` instead — their alpha is driven
* by the permanent plasma-beam pulse, so writing to it here would fight the
* animation every frame.
*/
export function updateAllocation(ctrl: SkillTreeController, allocated: ReadonlySet<string>) {
const active = allocated.size > 0;
for (const obj of ctrl.nodeObjects) {
const lit = !active || allocated.has(obj.node.id);
const alpha = lit ? 1 : DIMMED_NODE_ALPHA;
obj.graphics.alpha = alpha;
obj.glowGraphics.alpha = lit ? 1 : DIMMED_NODE_ALPHA * 0.5;
if (obj.label) obj.label.alpha = alpha;
}
for (const line of ctrl.lineObjects) {
const lit = !active || isEdgeAllocated(allocated, line.node);
const tint = lit ? 0xffffff : DIMMED_LINE_TINT;
line.graphics.tint = tint;
line.glowGraphics.tint = tint;
line.plasmaBeam.tint = tint;
}
}
+109 -8
View File
@@ -4,24 +4,84 @@
import { Container } from 'pixi.js';
import { gsap } from 'gsap';
import { getDistanceFromOrigin } from '$lib/utils/tree';
import { updateParallax } from './interaction';
import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
import { updateParallax, updateLabels } from './interaction';
import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
/** Handle returned by runEntranceAnimations. */
export interface EntranceHandle {
/** Fast-forward the intro to its finished state. */
skip: () => void;
/** Total intro length in ms, so the UI can time Skip and the hint off it. */
durationMs: number;
}
interface EntranceOpts {
isMobile: boolean;
life: Lifecycle;
/** Returning visitor — land on the settled tree instead of replaying it. */
skipIntro?: boolean;
/** Called when the welcome title should be removed (matches its CSS fade). */
onTitleDone: () => void;
}
export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) {
export function runEntranceAnimations(
ctrl: SkillTreeController,
opts: EntranceOpts
): EntranceHandle {
const { isMobile } = opts;
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
// Responsive animation timing
const titleDuration = isMobile ? 1.5 : 2.5;
const zoomDuration = isMobile ? 2.5 : 4.5;
const nodeStagger = isMobile ? 0.05 : 0.08;
/** Put the tree straight into its settled, interactive state. */
function showFinishedTree() {
treeContainer.scale.set(endScale);
updateLabels(ctrl);
for (const obj of nodeObjects) {
obj.graphics.alpha = 1;
obj.graphics.scale.set(1);
obj.glowGraphics.alpha = 1;
if (obj.label) obj.label.alpha = 1;
if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static';
}
for (const line of lineObjects) {
line.graphics.alpha = 1;
line.glowGraphics.alpha = 1;
line.plasmaBeam.alpha = 1;
}
const toggle = document.querySelector<HTMLElement>('.stats-panel-toggle');
if (toggle) toggle.style.opacity = '1';
opts.onTitleDone();
}
// Reduced motion, or a returning visitor who has already seen the intro:
// no zoom, no stagger, and no `repeat: -1` pulses.
if (prefersReducedMotion() || opts.skipIntro) {
showFinishedTree();
return { skip: () => {}, durationMs: 0 };
}
// Timings come from config so there is a single place to tune them. They used
// to be duplicated here as literals, so editing config.ts did nothing.
const titleDuration = byDevice(TIMINGS.titleDuration, isMobile);
const zoomDuration = byDevice(TIMINGS.zoomDuration, isMobile);
const nodeStagger = byDevice(TIMINGS.nodeStagger, isMobile);
// The entrance gets its own nested GSAP context so Skip can enumerate exactly
// its tweens. Nested contexts register with the parent, so the lifecycle
// still tears everything down in one call.
let entranceCtx: gsap.Context | null = null;
// 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(() => {
entranceCtx = gsap.context(() => {
// Hide all lines and plasma beams initially
lineObjects.forEach((line) => {
line.graphics.alpha = 0;
@@ -78,6 +138,14 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
delay: originDelay,
ease: 'power2.out'
});
if (originNode.label) {
gsap.to(originNode.label, {
alpha: 1,
duration: originDuration,
delay: originDelay,
ease: 'power2.out'
});
}
}
// STEP 2: Start zoom out after origin appears
@@ -89,7 +157,10 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
duration: zoomDuration,
delay: zoomDelay,
ease: 'power1.out',
onUpdate: () => updateParallax(ctrl)
onUpdate: () => {
updateParallax(ctrl);
updateLabels(ctrl);
}
});
// STEP 3: Animate in the rest of the nodes (excluding origin)
@@ -124,6 +195,12 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
ease: 'power2.out'
});
// Labels arrive with their node, never before it — otherwise they show
// over invisible nodes during the splash.
if (obj.label) {
gsap.to(obj.label, { alpha: 1, duration: 0.5, delay, ease: 'power2.out' });
}
// Start ambient pulse after node appears
gsap.to(obj.glowGraphics.scale, {
x: 1.4,
@@ -202,12 +279,13 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
});
// Clean up title card after animation (matches titleDuration)
setTimeout(
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');
@@ -219,4 +297,27 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
ease: 'power2.out'
});
}
});
});
// When the last node/line finishes appearing. Derived rather than guessed, so
// the Skip control and the coach hint stay in step with the real animation.
const lastIndex = Math.max(nodeObjects.length, lineObjects.length) - 1;
const durationMs = Math.round(
(byDevice(TIMINGS.titleDuration, isMobile) + 0.5 + lastIndex * nodeStagger + 0.5) * 1000
);
return {
durationMs,
skip() {
// progress(1) completes each finite tween AND fires its onComplete —
// which is what re-enables node interactivity and starts the ambient
// pulses. So skipping lands in the same state as watching it through.
// Infinite pulses are left alone; fast-forwarding them is meaningless.
for (const tween of entranceCtx?.getTweens() ?? []) {
if (tween.repeat() === -1) continue;
tween.progress(1);
}
}
};
}
+63 -21
View File
@@ -4,11 +4,17 @@
import { Application, Graphics, Container } from 'pixi.js';
import { gsap } from 'gsap';
import { nebulaPalette } from '$lib/theme';
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
import { PARTICLE_COUNTS, byDevice, prefersReducedMotion } from '$lib/config';
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();
let vignette!: Graphics;
const nebulaContainer = new Container();
const starContainer = new Container();
@@ -16,6 +22,21 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
app.stage.addChild(nebulaContainer);
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.
const reduceMotion = prefersReducedMotion();
/**
* Every animation in this file is an infinite ambient loop (twinkle, drift,
* nebula breathing). Routing them through one helper means reduced motion
* never creates them, rather than creating and immediately killing them.
*/
function ambient(target: object, vars: gsap.TweenVars) {
if (reduceMotion) return;
ambient(target, vars);
}
life.animate(() => {
const screenWidth = app.screen.width;
const screenHeight = app.screen.height;
const centerX = screenWidth / 2;
@@ -78,7 +99,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
nebulaContainer.addChild(nebulaCloud);
// Animate nebula pulsing
gsap.to(nebulaCloud, {
ambient(nebulaCloud, {
alpha: 0.6,
duration: 3 + Math.random() * 2,
ease: 'sine.inOut',
@@ -87,7 +108,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
delay: Math.random() * 2
});
gsap.to(nebulaCloud.scale, {
ambient(nebulaCloud.scale, {
x: 1.1,
y: 1.1,
duration: 4 + Math.random() * 2,
@@ -112,7 +133,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
star.y = Math.random() * screenHeight;
starContainer.addChild(star);
gsap.to(star, {
ambient(star, {
alpha: alpha * 0.3,
duration: 1 + Math.random() * 2,
ease: 'sine.inOut',
@@ -139,7 +160,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
star.y = Math.random() * screenHeight;
starContainer.addChild(star);
gsap.to(star, {
ambient(star, {
alpha: alpha * 0.4,
duration: 0.5 + Math.random() * 1.5,
ease: 'sine.inOut',
@@ -164,7 +185,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
star.y = Math.random() * screenHeight;
starContainer.addChild(star);
gsap.to(star.scale, {
ambient(star.scale, {
x: 1.3,
y: 1.3,
duration: 2 + Math.random() * 2,
@@ -199,7 +220,7 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
vy: (Math.random() - 0.5) * 0.2
});
gsap.to(dust, {
ambient(dust, {
alpha: 0.3,
duration: 3 + Math.random() * 2,
ease: 'sine.inOut',
@@ -209,21 +230,30 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
});
}
// Animate dust particles drifting
app.ticker.add(() => {
dustParticles.forEach((p) => {
p.graphics.x += p.vx;
p.graphics.y += p.vy;
// Animate dust particles drifting. Named function + indexed loop: `forEach`
// with an inline arrow allocated a fresh closure on every frame.
function driftDust() {
for (let i = 0; i < dustParticles.length; i++) {
const p = dustParticles[i];
const g = p.graphics;
g.x += p.vx;
g.y += p.vy;
if (p.graphics.x < -20) p.graphics.x = screenWidth + 20;
if (p.graphics.x > screenWidth + 20) p.graphics.x = -20;
if (p.graphics.y < -20) p.graphics.y = screenHeight + 20;
if (p.graphics.y > screenHeight + 20) p.graphics.y = -20;
});
});
if (g.x < -20) g.x = screenWidth + 20;
else if (g.x > screenWidth + 20) g.x = -20;
if (g.y < -20) g.y = screenHeight + 20;
else if (g.y > screenHeight + 20) g.y = -20;
}
}
// Drifting dust is continuous ambient motion — skip it entirely rather
// than run it slower.
if (!reduceMotion) {
app.ticker.add(driftDust);
life.add(() => app.ticker.remove(driftDust));
}
// ========== VIGNETTE OVERLAY ==========
const vignette = new Graphics();
vignette = new Graphics();
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
for (let i = 12; i >= 0; i--) {
@@ -234,5 +264,17 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
}
app.stage.addChild(vignette);
return { deepBackgroundContainer, nebulaContainer, starContainer };
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
// never change — bake them to a texture instead of recompositing every frame.
deepBackgroundContainer.cacheAsTexture(true);
vignette.cacheAsTexture(true);
// Nothing in the background is clickable; skip it during hit-testing.
deepBackgroundContainer.interactiveChildren = false;
nebulaContainer.interactiveChildren = false;
starContainer.interactiveChildren = false;
vignette.eventMode = 'none';
});
return { deepBackgroundContainer, nebulaContainer, starContainer, vignette };
}
+5 -1
View File
@@ -29,6 +29,9 @@ export interface SkillTreeController {
initialTreeX: number;
initialTreeY: number;
endScale: number;
/** Labels pinned on by the user ('L'), overriding the zoom threshold. */
labelsForced: boolean;
}
export function createController(
@@ -53,6 +56,7 @@ export function createController(
isTouchZooming: false,
initialTreeX: treeContainer.x,
initialTreeY: treeContainer.y,
endScale
endScale,
labelsForced: false
};
}
+310 -23
View File
@@ -4,8 +4,110 @@
// handlers read/write it by reference. attach* functions return a cleanup that
// removes the window-level listeners they added.
import { gsap } from 'gsap';
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion, LABEL_ZOOM_THRESHOLD } from '$lib/config';
import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
/**
* Fade node labels in once the tree is zoomed in far enough to have room for
* them, and counter-scale so text stays a constant size on screen rather than
* ballooning with the zoom. Called on every zoom change (not per frame).
*/
/** Screen-space rectangle, used for label collision tests. */
interface Box {
left: number;
top: number;
right: number;
bottom: number;
}
function overlaps(a: Box, b: Box): boolean {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}
/** Bigger nodes win a contested label. */
const SIZE_PRIORITY: Record<string, number> = {
'x-large': 0,
large: 1,
medium: 2,
small: 3
};
export function updateLabels(ctrl: SkillTreeController) {
const { treeContainer, nodeObjects } = ctrl;
const scale = treeContainer.scale.x;
const visible = ctrl.labelsForced || scale >= LABEL_ZOOM_THRESHOLD;
const inverse = 1 / scale;
if (!visible) {
for (const obj of nodeObjects) if (obj.label) obj.label.visible = false;
return;
}
// Every node's footprint on screen. A label may never cover a node other
// than its own.
const nodeBoxes = new Map<string, Box>();
for (const obj of nodeObjects) {
const g = obj.graphics;
const w = (g.width || 0) / 2;
const h = (g.height || 0) / 2;
const cx = treeContainer.x + obj.node.x * scale;
const cy = treeContainer.y + obj.node.y * scale;
nodeBoxes.set(obj.node.id, {
left: cx - w,
top: cy - h,
right: cx + w,
bottom: cy + h
});
}
// Draw labels biggest-node-first, and drop any that would collide with a
// label already placed or with someone else's node. In dense clusters this
// simply shows fewer labels until the user zooms further in.
const ordered = [...nodeObjects].sort(
(a, b) => (SIZE_PRIORITY[a.node.size] ?? 9) - (SIZE_PRIORITY[b.node.size] ?? 9)
);
const placed: Box[] = [];
const PAD = 2;
for (const obj of ordered) {
const label = obj.label;
if (!label) continue;
// Counter-scale so the text stays a constant size on screen instead of
// growing with the zoom. Alpha is deliberately NOT set here — it is owned
// by the entrance animation, so a label can't appear over a node that has
// not faded in yet.
label.scale.set(inverse);
const screenW = label.width * scale;
const screenH = label.height * scale;
const cx = treeContainer.x + label.x * scale;
const top = treeContainer.y + label.y * scale;
const box: Box = {
left: cx - screenW / 2 - PAD,
top: top - PAD,
right: cx + screenW / 2 + PAD,
bottom: top + screenH + PAD
};
let blocked = placed.some((other) => overlaps(box, other));
if (!blocked) {
for (const [id, nodeBox] of nodeBoxes) {
if (id === obj.node.id) continue;
if (overlaps(box, nodeBox)) {
blocked = true;
break;
}
}
}
label.visible = !blocked;
if (!blocked) placed.push(box);
}
}
/** Offset the background layers behind the tree to create parallax depth. */
export function updateParallax(ctrl: SkillTreeController) {
@@ -25,16 +127,31 @@ export function updateParallax(ctrl: SkillTreeController) {
interface PanZoomOpts {
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 function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void {
export interface PanZoomHandle {
/**
* 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 minZoom = ZOOM.min;
const maxZoom = ZOOM.max;
// ===== MOUSE PANNING =====
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
life.listen(app.canvas, 'mousedown', (e: MouseEvent) => {
ctrl.isDragging = true;
ctrl.hasMoved = false;
ctrl.dragStartX = e.clientX;
@@ -55,23 +172,47 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
treeContainer.y = ctrl.containerStartY + dy;
updateParallax(ctrl);
};
window.addEventListener('mousemove', onWindowMouseMove);
life.listen(window, 'mousemove', onWindowMouseMove);
const onWindowMouseUp = () => {
ctrl.isDragging = false;
app.canvas.style.cursor = 'grab';
};
window.addEventListener('mouseup', onWindowMouseUp);
life.listen(window, 'mouseup', onWindowMouseUp);
app.canvas.style.cursor = 'grab';
// Track mouse position for tooltip
app.canvas.addEventListener('mousemove', (e: MouseEvent) => {
opts.onTooltipMove(e.clientX, e.clientY);
// Track mouse position for the tooltip.
//
// 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 =====
app.canvas.addEventListener(
life.listen(
app.canvas,
'touchstart',
(e: TouchEvent) => {
if (e.touches.length === 1) {
@@ -94,7 +235,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
{ passive: true }
);
app.canvas.addEventListener(
life.listen(
app.canvas,
'touchmove',
(e: TouchEvent) => {
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
@@ -120,6 +262,7 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
// Zoom centered on origin node (same as mouse wheel zoom)
treeContainer.scale.set(newScale);
updateParallax(ctrl);
updateLabels(ctrl);
}
ctrl.lastTouchDistance = distance;
@@ -129,7 +272,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
{ passive: true }
);
app.canvas.addEventListener(
life.listen(
app.canvas,
'touchend',
(e: TouchEvent) => {
if (e.touches.length === 0) {
@@ -150,7 +294,15 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
);
// ===== 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) => {
// Ctrl+wheel is the browser's page-zoom gesture (and trackpad pinch);
// swallowing it broke zooming for anyone who relies on it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
const oldScale = treeContainer.scale.x;
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
@@ -160,28 +312,135 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
treeContainer.scale.set(newScale);
updateParallax(ctrl);
updateLabels(ctrl);
updateLabels(ctrl);
},
{ passive: false }
);
return { flushTooltip };
}
/**
* Centre the view on a node in tree-space. Used by the accessible tree
* navigator so keyboard focus and the canvas stay in sync.
*/
export function panToNode(
ctrl: SkillTreeController,
x: number,
y: number,
life: Lifecycle,
options: { scale?: number; duration?: number } = {}
) {
const { app, treeContainer } = ctrl;
const targetScale = options.scale ?? treeContainer.scale.x;
const targetX = app.screen.width / 2 - x * targetScale;
const targetY = app.screen.height / 2 - y * targetScale;
// Reduced motion jumps instead of flying — the objection is to the camera
// movement, not to being taken somewhere.
const duration = prefersReducedMotion() ? 0 : (options.duration ?? 0.4);
life.animate(() => {
const sync = () => {
updateParallax(ctrl);
updateLabels(ctrl);
};
gsap.to(treeContainer, {
x: targetX,
y: targetY,
duration,
ease: 'power2.inOut',
overwrite: 'auto',
onUpdate: sync,
onComplete: () => {
// Re-baseline parallax so later panning stays consistent.
ctrl.initialTreeX = treeContainer.x;
ctrl.initialTreeY = treeContainer.y;
}
});
return () => {
window.removeEventListener('mousemove', onWindowMouseMove);
window.removeEventListener('mouseup', onWindowMouseUp);
};
gsap.to(treeContainer.scale, {
x: targetScale,
y: targetScale,
duration,
ease: 'power2.inOut',
overwrite: 'auto',
onUpdate: sync
});
});
}
interface KeyboardOpts {
onToggleStats: () => void;
/** Cmd/Ctrl-K — open the search palette. */
onOpenSearch: () => void;
isSearchOpen: () => boolean;
/** 'J' — start the guided career journey. */
onStartJourney: () => void;
isJourneyActive: () => boolean;
onCloseModal: () => void;
isModalVisible: () => boolean;
isStatsVisible: () => boolean;
}
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void {
/** Narrow an event target to an Element, or null. */
function target0(e: Event): Element | null {
return e.target instanceof Element ? e.target : null;
}
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
const { app, treeContainer } = ctrl;
const minZoom = ZOOM.min;
const maxZoom = ZOOM.max;
function handleKeydown(e: KeyboardEvent) {
// Don't hijack keys the browser or another widget owns.
//
// - Modifier combos belong to the browser (Ctrl+Plus/Minus zoom, etc).
// - Typing in a field must never pan the canvas.
// - Arrow keys inside a scrollable overlay (the 380px stats panel, the
// modal body) must scroll it, not pan the tree behind it. Previously
// every arrow press called preventDefault() unconditionally, so those
// regions could not be scrolled by keyboard at all.
// Cmd/Ctrl-K opens search. Checked before the modifier bail-out below,
// since it is itself a modifier combo.
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
opts.onOpenSearch();
return;
}
// While the palette or the guided journey is open, it owns the keyboard.
if (opts.isSearchOpen() || opts.isJourneyActive()) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
// `instanceof Element`, not a truthiness check: a keydown dispatched on
// `window` has `window` as its target, which has no tagName/closest — and
// calling closest() on it throws, killing the whole handler (Escape
// included).
const target = e.target instanceof Element ? target0(e) : null;
if (target) {
const tag = target.tagName;
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(target as HTMLElement).isContentEditable ||
target.closest('#stats-panel, [role="dialog"]')
) {
// Escape still needs to work from inside those regions.
if (e.key === 'Escape') {
if (opts.isModalVisible()) opts.onCloseModal();
else if (opts.isStatsVisible()) opts.onToggleStats();
}
return;
}
}
// Toggle stats panel with 'C' key
if (e.key === 'c' || e.key === 'C') {
if (!opts.isModalVisible()) {
@@ -227,6 +486,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax(ctrl);
updateLabels(ctrl);
updateLabels(ctrl);
}
// - for zoom out
@@ -236,17 +497,39 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax(ctrl);
updateLabels(ctrl);
updateLabels(ctrl);
}
// J starts the guided career journey.
if (e.key === 'j' || e.key === 'J') {
e.preventDefault();
opts.onStartJourney();
return;
}
// L forces labels on/off regardless of zoom.
if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
ctrl.labelsForced = !ctrl.labelsForced;
updateLabels(ctrl);
}
// R to reset view to origin
if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
// Re-enter the GSAP context: these are created per keypress.
life.animate(() => {
gsap.to(treeContainer, {
x: app.screen.width / 2,
y: app.screen.height / 2,
duration: 0.5,
ease: 'power2.out',
onUpdate: () => updateParallax(ctrl),
overwrite: 'auto',
onUpdate: () => {
updateParallax(ctrl);
updateLabels(ctrl);
},
onComplete: () => {
// Update parallax baseline after reset
ctrl.initialTreeX = treeContainer.x;
@@ -258,11 +541,15 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
y: ctrl.endScale,
duration: 0.5,
ease: 'power2.out',
onUpdate: () => updateParallax(ctrl)
overwrite: 'auto',
onUpdate: () => {
updateParallax(ctrl);
updateLabels(ctrl);
}
});
});
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
life.listen(window, '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>;
+72 -15
View File
@@ -1,18 +1,20 @@
// Tree node rendering: the origin profile node and regular icon nodes.
// Nodes start non-interactive (eventMode 'none') until the entrance animation
// enables them; see animation.ts.
import { Graphics, Container, Assets, Sprite } from 'pixi.js';
import { Graphics, Container, Assets, Sprite, Text, TextStyle, type Texture } from 'pixi.js';
import { gsap } from 'gsap';
import { treeNodes, type TreeNode } from '$lib/data';
import { categoryColors } from '$lib/theme';
import { categoryColors, categoryColorHex } from '$lib/theme';
import { getNodeRadius } from '$lib/utils/tree';
import type { NodeObject } from './types';
import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
interface NodeOpts {
isMobile: boolean;
isTouchDevice: boolean;
controller: SkillTreeController;
life: Lifecycle;
onOpen: (node: TreeNode) => void;
onHover: (node: TreeNode | null) => void;
}
@@ -34,24 +36,76 @@ 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', () => {
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2 });
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2 });
opts.life.animate(() => {
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);
});
target.on('pointerout', () => {
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2 });
gsap.to(glowGraphics, { alpha: 1, duration: 0.2 });
opts.life.animate(() => {
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);
});
}
/**
* One shared TextStyle per category. Pixi rasterises Text to a texture, so
* reusing styles keeps that cheap; labels also start invisible and are only
* revealed above the zoom threshold.
*/
function makeLabel(node: TreeNode, radius: number, isMobile: boolean): Text {
const style = new TextStyle({
fontFamily: 'system-ui, sans-serif',
fontSize: isMobile ? 11 : 12,
fontWeight: '600',
fill: categoryColorHex[node.category],
align: 'center',
stroke: { color: '#05050a', width: 3, join: 'round' }
});
// Long labels are the main source of collisions ("Data Structures &
// Algorithms" is nearly 3x a node's diameter). The full text is always
// available in the tooltip and modal.
const MAX_LABEL_CHARS = 18;
const text =
node.label.length > MAX_LABEL_CHARS
? `${node.label.slice(0, MAX_LABEL_CHARS - 1)}…`
: node.label;
const label = new Text({ text, style });
label.anchor.set(0.5, 0);
label.x = node.x;
label.y = node.y + radius + 6;
label.alpha = 0;
label.eventMode = 'none';
label.resolution = Math.min(window.devicePixelRatio || 1, 2);
return label;
}
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
const { isMobile } = opts;
const nodeObjects: NodeObject[] = [];
const profileTexture = await Assets.load('/headshot.jpg');
// Load every texture in ONE parallel batch. Previously each icon was awaited
// inside the loop below, which serialised ~56 round-trips before the tree
// could render or accept input.
const iconUrls = [...new Set(treeNodes.map((n) => n.icon).filter((s): s is string => !!s))];
const [profileTexture, iconTextures] = await Promise.all([
Assets.load<Texture>('/headshot.jpg'),
// Array form resolves to a url -> Texture map and fetches concurrently.
iconUrls.length
? Assets.load<Texture>(iconUrls)
: Promise.resolve({} as Record<string, Texture>)
]);
for (const node of treeNodes) {
const radius = getNodeRadius(node.size, isMobile);
@@ -96,7 +150,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
treeContainer.addChild(profileContainer);
nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
const originLabel = makeLabel(node, radius, isMobile);
treeContainer.addChild(originLabel);
nodeObjects.push({ graphics: profileContainer, node, glowGraphics, label: originLabel });
} else {
const nodeContainer = new Container();
nodeContainer.x = node.x;
@@ -107,9 +163,11 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
nodeContainer.addChild(nodeGraphics);
if (node.icon) {
try {
const iconTexture = await Assets.load(node.icon);
const iconTexture = node.icon ? iconTextures[node.icon] : undefined;
if (node.icon && !iconTexture) {
console.warn(`Could not load icon for ${node.id}:`, node.icon);
}
if (iconTexture) {
const iconSprite = new Sprite(iconTexture);
const iconSize = radius * 1.4;
@@ -119,9 +177,6 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
iconSprite.tint = color;
nodeContainer.addChild(iconSprite);
} catch {
console.warn(`Could not load icon for ${node.id}:`, node.icon);
}
}
nodeContainer.alpha = 0;
@@ -134,7 +189,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
treeContainer.addChild(nodeContainer);
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
const nodeLabel = makeLabel(node, radius, isMobile);
treeContainer.addChild(nodeLabel);
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics, label: nodeLabel });
}
}
+6 -2
View File
@@ -1,5 +1,5 @@
// Shared structural types for the PixiJS skill-tree rendering modules.
import type { Graphics, Container } from 'pixi.js';
import type { Graphics, Container, Text } from 'pixi.js';
import type { TreeNode } from '$lib/data';
/** A parent→child connection: glow + core line + animated plasma beam. */
@@ -17,11 +17,15 @@ export interface NodeObject {
graphics: Graphics | Container;
node: TreeNode;
glowGraphics: Graphics;
/** On-canvas label, shown above the zoom threshold. */
label?: Text;
}
/** The three parallax background layers (vignette lives on the stage). */
/** The parallax background layers plus the vignette overlay. */
export interface BackgroundLayers {
deepBackgroundContainer: Container;
nebulaContainer: Container;
starContainer: Container;
/** Static overlay on the stage; returned so a resize rebuild can destroy it. */
vignette: Graphics;
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import {
pathToRoot,
allocate,
deallocate,
isEdgeAllocated,
encodeBuild,
decodeBuild
} from './allocation';
import { treeNodes, type TreeNode } from '$lib/data';
const node = (id: string, parentId: string | null): TreeNode =>
({
id,
label: id,
description: '',
x: 0,
y: 0,
parentId,
category: 'skills',
size: 'small'
}) as TreeNode;
const chain = [node('root', null), node('mid', 'root'), node('leaf', 'mid')];
describe('pathToRoot', () => {
it('walks a leaf up to the root inclusive', () => {
expect(pathToRoot('leaf', chain)).toEqual(['leaf', 'mid', 'root']);
});
it('returns just the root for the root', () => {
expect(pathToRoot('root', chain)).toEqual(['root']);
});
it('returns nothing for an unknown id', () => {
expect(pathToRoot('nope', chain)).toEqual([]);
});
it('terminates on a cyclic parent chain', () => {
const cyclic = [node('a', 'b'), node('b', 'a')];
expect(pathToRoot('a', cyclic).length).toBe(2);
});
it('reaches the origin for every real node', () => {
for (const n of treeNodes) {
expect(pathToRoot(n.id).at(-1)).toBe('origin');
}
});
});
describe('allocate / deallocate', () => {
it('allocating a leaf brings its whole path', () => {
expect([...allocate([], 'leaf', chain)].sort()).toEqual(['leaf', 'mid', 'root']);
});
it('is idempotent', () => {
const once = allocate([], 'leaf', chain);
expect([...allocate(once, 'leaf', chain)].sort()).toEqual([...once].sort());
});
it('deallocating removes descendants so nothing is orphaned', () => {
const full = allocate([], 'leaf', chain);
expect([...deallocate(full, 'mid', chain)]).toEqual(['root']);
});
it('deallocating a leaf leaves its ancestors alone', () => {
const full = allocate([], 'leaf', chain);
expect([...deallocate(full, 'leaf', chain)].sort()).toEqual(['mid', 'root']);
});
it('does not mutate the input', () => {
const start = new Set(['root']);
allocate(start, 'leaf', chain);
expect([...start]).toEqual(['root']);
});
});
describe('isEdgeAllocated', () => {
it('is true only when both ends are allocated', () => {
const allocated = new Set(['root', 'mid']);
expect(isEdgeAllocated(allocated, chain[1])).toBe(true);
expect(isEdgeAllocated(allocated, chain[2])).toBe(false);
});
it('is false for a root node, which has no edge', () => {
expect(isEdgeAllocated(new Set(['root']), chain[0])).toBe(false);
});
});
describe('encodeBuild / decodeBuild', () => {
it('round-trips', () => {
const build = allocate([], 'lang-typescript');
expect(decodeBuild(encodeBuild(build))).toEqual(build);
});
it('is order-independent, so the same build is always the same link', () => {
expect(encodeBuild(['b', 'a', 'c'])).toBe(encodeBuild(['c', 'b', 'a']));
});
it('returns an empty build for empty or missing input', () => {
expect(decodeBuild(null).size).toBe(0);
expect(decodeBuild('').size).toBe(0);
});
it('discards ids that no longer exist', () => {
expect(decodeBuild('lang-typescript~was-deleted').has('was-deleted')).toBe(false);
expect(decodeBuild('lang-typescript~was-deleted').has('lang-typescript')).toBe(true);
});
it('repairs a hand-edited link that skips ancestors', () => {
// Only the leaf in the URL — the path must come back with it.
const decoded = decodeBuild('lang-typescript');
expect(decoded.has('origin')).toBe(true);
expect(decoded.has('languages')).toBe(true);
});
});
+93
View File
@@ -0,0 +1,93 @@
// ==========================================================================
// ALLOCATION — Path-of-Exile style node allocation. Pure; no DOM, no PixiJS.
// ==========================================================================
import { treeNodes, type TreeNode } from '$lib/data';
/**
* Every node from `id` up to the root, inclusive.
*
* Allocation is path-connected like the game it borrows from: you cannot
* light a leaf without the branch that reaches it. Cycle-safe — a malformed
* parent chain terminates rather than looping forever.
*/
export function pathToRoot(id: string, nodes: TreeNode[] = treeNodes): string[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
const path: string[] = [];
const seen = new Set<string>();
let current = byId.get(id);
while (current && !seen.has(current.id)) {
seen.add(current.id);
path.push(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path;
}
/**
* Allocate a node: adds it and its whole path to the root.
* Returns a new set — callers hold reactive state.
*/
export function allocate(allocated: Iterable<string>, id: string, nodes = treeNodes): Set<string> {
const next = new Set(allocated);
for (const nodeId of pathToRoot(id, nodes)) next.add(nodeId);
return next;
}
/**
* Deallocate a node and everything downstream of it, since orphaned children
* would violate path-connectedness.
*/
export function deallocate(
allocated: Iterable<string>,
id: string,
nodes = treeNodes
): Set<string> {
const next = new Set(allocated);
// Collect descendants breadth-first.
const doomed = [id];
while (doomed.length) {
const current = doomed.pop() as string;
next.delete(current);
for (const node of nodes) {
if (node.parentId === current && next.has(node.id)) doomed.push(node.id);
}
}
return next;
}
/** Is this edge (child → its parent) fully allocated? */
export function isEdgeAllocated(allocated: ReadonlySet<string>, node: TreeNode): boolean {
return !!node.parentId && allocated.has(node.id) && allocated.has(node.parentId);
}
/**
* Encode for a shareable URL.
*
* Node ids rather than indices: a bitset would be shorter, but any edit to
* data.ts would silently re-point every previously shared link at the wrong
* nodes. Ids only break if an id itself is renamed, which is both rarer and
* fails loudly (the node is simply skipped).
*/
export function encodeBuild(allocated: Iterable<string>): string {
return [...allocated].sort().join('~');
}
/** Decode a ?build= value, discarding anything that no longer exists. */
export function decodeBuild(value: string | null, nodes: TreeNode[] = treeNodes): Set<string> {
if (!value) return new Set();
const valid = new Set(nodes.map((n) => n.id));
const result = new Set<string>();
for (const id of value.split('~')) {
if (!valid.has(id)) continue;
// Re-derive the path so a hand-edited link can't produce a broken build.
for (const nodeId of pathToRoot(id, nodes)) result.add(nodeId);
}
return result;
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { buildHierarchy, flattenVisible } from './tree';
import { treeNodes, type TreeNode } from '$lib/data';
const node = (id: string, parentId: string | null): TreeNode =>
({
id,
label: id,
description: '',
x: 0,
y: 0,
parentId,
category: 'skills',
size: 'small'
}) as TreeNode;
describe('buildHierarchy', () => {
it('nests children under their parent', () => {
const roots = buildHierarchy([node('a', null), node('b', 'a'), node('c', 'b')]);
expect(roots).toHaveLength(1);
expect(roots[0].node.id).toBe('a');
expect(roots[0].children[0].node.id).toBe('b');
expect(roots[0].children[0].children[0].node.id).toBe('c');
});
it('treats a node with an unknown parent as a root rather than dropping it', () => {
const roots = buildHierarchy([node('a', null), node('orphan', 'missing')]);
expect(roots.map((r) => r.node.id).sort()).toEqual(['a', 'orphan']);
});
it('covers every real node exactly once', () => {
const seen = new Set<string>();
const walk = (branches: ReturnType<typeof buildHierarchy>) => {
for (const b of branches) {
expect(seen.has(b.node.id)).toBe(false);
seen.add(b.node.id);
walk(b.children);
}
};
walk(buildHierarchy());
expect(seen.size).toBe(treeNodes.length);
});
it('every parentId in the real data resolves to a real node', () => {
const ids = new Set(treeNodes.map((n) => n.id));
const dangling = treeNodes.filter((n) => n.parentId !== null && !ids.has(n.parentId));
expect(dangling.map((n) => n.id)).toEqual([]);
});
});
describe('flattenVisible', () => {
it('only descends into expanded branches, and reports depth', () => {
const roots = buildHierarchy([node('a', null), node('b', 'a'), node('c', 'b')]);
expect(flattenVisible(roots, () => false).map((r) => r.branch.node.id)).toEqual(['a']);
expect(flattenVisible(roots, (id) => id === 'a').map((r) => r.branch.node.id)).toEqual([
'a',
'b'
]);
const all = flattenVisible(roots, () => true);
expect(all.map((r) => r.branch.node.id)).toEqual(['a', 'b', 'c']);
expect(all.map((r) => r.level)).toEqual([1, 2, 3]);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { getJourneyStops, findStopIndex, slugify } from './journey';
const stops = getJourneyStops();
describe('slugify', () => {
it('normalises company names', () => {
expect(slugify('Bluewater Federal Solutions')).toBe('bluewater-federal-solutions');
expect(slugify('Insight Global')).toBe('insight-global');
});
it('trims leading and trailing separators', () => {
expect(slugify(' Peraton! ')).toBe('peraton');
});
});
describe('getJourneyStops', () => {
it('opens on the origin', () => {
expect(stops[0].slug).toBe('start');
expect(stops[0].node.category).toBe('origin');
});
it('includes every real job and no container nodes', () => {
const jobs = stops.slice(1);
expect(jobs.length).toBeGreaterThanOrEqual(5);
expect(jobs.every((s) => s.node.category === 'experience')).toBe(true);
expect(jobs.every((s) => !!s.title)).toBe(true);
});
it('runs oldest-first so it reads as progression', () => {
const years = stops.slice(1).map((s) => Number(s.kicker.match(/\d{4}/)?.[0]));
const sorted = [...years].sort((a, b) => a - b);
expect(years).toEqual(sorted);
});
it('ends on the current role', () => {
expect(stops[stops.length - 1].kicker).toMatch(/Present$/);
});
it('caps each stop at two points so the card stays readable', () => {
expect(stops.every((s) => s.points.length <= 2)).toBe(true);
});
it('gives every stop a unique slug', () => {
const slugs = stops.map((s) => s.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
});
describe('findStopIndex', () => {
it('finds a stop by slug', () => {
expect(findStopIndex(stops, 'peraton')).toBeGreaterThan(0);
expect(stops[findStopIndex(stops, 'peraton')].title).toBe('Peraton');
});
it('is forgiving about case and punctuation', () => {
expect(findStopIndex(stops, 'Peraton')).toBe(findStopIndex(stops, 'peraton'));
});
it('returns -1 for an unknown or missing slug', () => {
expect(findStopIndex(stops, 'nope')).toBe(-1);
expect(findStopIndex(stops, null)).toBe(-1);
});
});
+73
View File
@@ -0,0 +1,73 @@
// ==========================================================================
// JOURNEY — the guided career narrative. Pure; derived from data.ts so the
// tour can never drift from the tree.
// ==========================================================================
import { treeNodes, type TreeNode, type ExperienceNode, type OriginNode } from '$lib/data';
export interface JourneyStop {
/** Stable slug for deep links (?tour=peraton). */
slug: string;
node: TreeNode;
kicker: string;
title: string;
subtitle?: string;
points: string[];
/** Zoom to settle at for this stop. */
scale: number;
}
/** "Bluewater Federal Solutions" -> "bluewater-federal-solutions" */
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* The tour: an intro on the origin, then every real job oldest-first, so the
* story reads as progression rather than a résumé's reverse-chronology.
*/
export function getJourneyStops(nodes: TreeNode[] = treeNodes): JourneyStop[] {
const stops: JourneyStop[] = [];
const origin = nodes.find((n): n is OriginNode => n.category === 'origin');
if (origin) {
stops.push({
slug: 'start',
node: origin,
kicker: 'The short version',
title: origin.fullName ?? origin.label,
subtitle: origin.jobTitle,
points: origin.tagline ? [origin.tagline] : [],
scale: 1.1
});
}
const jobs = nodes.filter(
(n): n is ExperienceNode => n.category === 'experience' && !!n.company && !!n.startDate
);
for (const job of jobs) {
stops.push({
slug: slugify(job.company as string),
node: job,
kicker: `${job.startDate} – ${job.endDate || 'Present'}`,
title: job.company as string,
subtitle: job.jobTitle,
// Two at most: the card has to stay readable at a glance.
points: (job.highlights ?? []).slice(0, 2),
scale: 1.35
});
}
return stops;
}
/** Resolve a ?tour= value to a stop index. -1 when it matches nothing. */
export function findStopIndex(stops: JourneyStop[], slug: string | null): number {
if (!slug) return -1;
const wanted = slugify(slug);
return stops.findIndex((s) => s.slug === wanted || s.slug.startsWith(wanted));
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { searchNodes } from './search';
describe('searchNodes', () => {
it('returns nothing for an empty query', () => {
expect(searchNodes('')).toEqual([]);
expect(searchNodes(' ')).toEqual([]);
});
it('ranks an exact label match first', () => {
expect(searchNodes('React')[0].node.label).toBe('React');
expect(searchNodes('Docker')[0].node.label).toBe('Docker');
});
it('is case-insensitive', () => {
expect(searchNodes('react')[0].node.id).toBe(searchNodes('REACT')[0].node.id);
});
it('matches a subsequence, not just a substring', () => {
const ids = searchNodes('wbrtc').map((r) => r.node.id);
expect(ids).toContain('be-webrtc');
});
it('prefers the shorter label when both match a prefix', () => {
expect(searchNodes('git')[0].node.label).toBe('Git');
});
it('finds projects by their tech stack', () => {
const results = searchNodes('Prisma');
expect(results.length).toBeGreaterThan(0);
expect(results.every((r) => r.node.category === 'projects')).toBe(true);
expect(results[0].context).toMatch(/^tech: /);
});
it('finds experience by company name', () => {
const results = searchNodes('Peraton');
expect(results[0].node.category).toBe('experience');
expect(results[0].context).toBe('Peraton');
});
it('returns nothing for a query that matches nothing', () => {
expect(searchNodes('zzzzqqqxyw')).toEqual([]);
});
it('never returns duplicate nodes', () => {
const ids = searchNodes('e').map((r) => r.node.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
+93
View File
@@ -0,0 +1,93 @@
// ==========================================================================
// SEARCH — subsequence matching over the tree data. Pure, no DOM, no PixiJS.
// ==========================================================================
import { treeNodes, type TreeNode } from '$lib/data';
export interface SearchResult {
node: TreeNode;
score: number;
/** Why it matched, for display ("React" vs "tech: React"). */
context?: string;
}
/**
* Case-insensitive subsequence match: "wbrtc" matches "WebRTC".
*
* Returns a score where lower is better, or null for no match. Consecutive
* runs and an early first match are rewarded, so "react" ranks the React node
* above a project that merely lists React in its stack.
*/
function subsequenceScore(haystack: string, needle: string): number | null {
if (!needle) return 0;
const h = haystack.toLowerCase();
const n = needle.toLowerCase();
// Exact and prefix matches short-circuit to the best scores.
if (h === n) return -1000;
if (h.startsWith(n)) return -500 + h.length;
let score = 0;
let hi = 0;
let lastHit = -1;
for (let ni = 0; ni < n.length; ni++) {
const found = h.indexOf(n[ni], hi);
if (found === -1) return null;
// Penalise gaps; reward adjacency.
score += found === lastHit + 1 ? 0 : found - hi + 1;
lastHit = found;
hi = found + 1;
}
// Prefer shorter haystacks — "Git" should beat "GitHub Actions" for "git".
return score + h.length * 0.1;
}
/** Search every node by label, then category, then description/tech stack. */
export function searchNodes(query: string, nodes: TreeNode[] = treeNodes): SearchResult[] {
const q = query.trim();
if (!q) return [];
const results: SearchResult[] = [];
for (const node of nodes) {
const labelScore = subsequenceScore(node.label, q);
if (labelScore !== null) {
results.push({ node, score: labelScore });
continue;
}
// Tech stack is a strong signal — searching "prisma" should surface the
// projects that use it even though no node is named Prisma.
if (node.category === 'projects' && node.techStack) {
const tech = node.techStack.find((t) => subsequenceScore(t, q) !== null);
if (tech) {
results.push({ node, score: 200, context: `tech: ${tech}` });
continue;
}
}
if (node.category === 'experience' && node.company) {
const companyScore = subsequenceScore(node.company, q);
if (companyScore !== null) {
results.push({ node, score: companyScore + 100, context: node.company });
continue;
}
}
// Descriptions use plain substring matching, not subsequence. Over long
// prose a subsequence match is almost always spurious — "Prisma" found a
// p...r...i...s...m...a spread across an unrelated sentence.
const haystack = node.description.toLowerCase();
const needle = q.toLowerCase();
const at = haystack.indexOf(needle);
if (at !== -1) {
results.push({
node,
score: 400 + at,
context: node.description.slice(Math.max(0, at - 20), at + 45).trim()
});
}
}
return results.sort((a, b) => a.score - b.score);
}
+18 -1
View File
@@ -4,7 +4,8 @@ import {
BRANCH_LABELS,
aggregateSkills,
countByProficiency,
getPlayerClass
getPlayerClass,
calculatePlayerLevel
} from './stats';
import { treeNodes } from '$lib/data';
@@ -55,3 +56,19 @@ describe('getPlayerClass', () => {
expect(getPlayerClass()).toBe(origin?.jobTitle);
});
});
describe('calculatePlayerLevel', () => {
it('derives the level from the earliest experience start date', () => {
// exp-job1 starts October 2020. Previously this date was hardcoded in
// stats.ts as well, so the two could drift apart silently.
const earliest = new Date('October 1, 2020').getTime();
const expected = Math.floor((Date.now() - earliest) / (1000 * 60 * 60 * 24 * 365.25));
expect(calculatePlayerLevel()).toBe(expected);
});
it('returns a plausible, non-negative level', () => {
const level = calculatePlayerLevel();
expect(level).toBeGreaterThanOrEqual(5);
expect(level).toBeLessThan(60);
});
});
+25 -3
View File
@@ -2,7 +2,7 @@
// STATS UTILS — pure aggregation for the RPG-style character panel.
// ==========================================================================
import { treeNodes, type SkillNode } from '$lib/data';
import { treeNodes, type SkillNode, type ExperienceNode } from '$lib/data';
/** Skill branch ids and their display labels (single source of truth). */
export const BRANCH_IDS = [
@@ -29,9 +29,31 @@ export const PROFICIENCY_ORDER: Record<string, number> = {
beginner: 3
};
/** Character "level" derived from years since the first job started. */
/**
* Parse a "Month YYYY" string (the format used by every startDate in data.ts)
* into a timestamp. Built explicitly rather than handed straight to `new Date`:
* "October 2020" alone is not a format the spec requires engines to accept.
*/
function parseMonthYear(value: string): number | null {
const [month, year] = value.trim().split(/\s+/);
if (!month || !year) return null;
const parsed = new Date(`${month} 1, ${year}`).getTime();
return Number.isNaN(parsed) ? null : parsed;
}
/**
* Character "level" derived from years since the first job started.
*
* The start date is read from the experience nodes rather than hardcoded — it
* previously duplicated exp-job1.startDate, so the two could silently drift.
*/
export function calculatePlayerLevel(): number {
const earliestStart = new Date('2020-10-01'); // First job
const starts = treeNodes
.filter((n): n is ExperienceNode => n.category === 'experience' && !!n.startDate)
.map((n) => parseMonthYear(n.startDate as string))
.filter((t): t is number => t !== null);
const earliestStart = new Date(starts.length ? Math.min(...starts) : Date.now());
const now = new Date();
const millisPerYear = 1000 * 60 * 60 * 24 * 365.25;
const years = (now.getTime() - earliestStart.getTime()) / millisPerYear;
+50 -1
View File
@@ -26,10 +26,59 @@ export function getDistanceFromOrigin(node: TreeNode): number {
* years of experience (0.3–1.0); other categories use fixed tiers.
*/
export function getBeamIntensity(node: TreeNode): number {
if (node.category === 'skills' && node.yearsOfExperience) {
if (node.category === 'skills' && node.yearsOfExperience !== undefined) {
return Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0);
}
if (node.category === 'experience') return 0.7;
if (node.category === 'origin') return 1.0;
return 0.5; // projects and education
}
/** A node plus its children, for the accessible `role="tree"` navigator. */
export interface TreeBranch {
node: TreeNode;
children: TreeBranch[];
}
/**
* Build the parent→child hierarchy implied by `parentId`.
*
* The canvas draws from the flat array, but an accessible tree widget needs
* real nesting to expose level/expanded state. Cycle-safe: any node whose
* parent chain doesn't terminate at a root is dropped rather than looping.
*/
export function buildHierarchy(nodes: TreeNode[] = treeNodes): TreeBranch[] {
const branches = new Map<string, TreeBranch>();
for (const node of nodes) branches.set(node.id, { node, children: [] });
const roots: TreeBranch[] = [];
for (const branch of branches.values()) {
const parentId = branch.node.parentId;
if (parentId === null) {
roots.push(branch);
continue;
}
const parent = branches.get(parentId);
// Unknown parent → treat as a root rather than silently dropping content.
if (!parent) roots.push(branch);
else parent.children.push(branch);
}
return roots;
}
/** Flatten a hierarchy into the currently-visible rows of a tree widget. */
export function flattenVisible(
branches: TreeBranch[],
isExpanded: (id: string) => boolean,
level = 1
): Array<{ branch: TreeBranch; level: number }> {
const rows: Array<{ branch: TreeBranch; level: number }> = [];
for (const branch of branches) {
rows.push({ branch, level });
if (branch.children.length && isExpanded(branch.node.id)) {
rows.push(...flattenVisible(branch.children, isExpanded, level + 1));
}
}
return rows;
}
+138 -19
View File
@@ -1,4 +1,8 @@
<script lang="ts">
// Self-hosted display face. Ships two subsets with unicode-range, so only
// the 26KB latin file is fetched for this content; font-display: swap is
// already set by the package, so text never blocks on it.
import '@fontsource-variable/cinzel';
import { onMount } from 'svelte';
import favicon from '$lib/assets/favicon.ico';
import '../app.css';
@@ -42,38 +46,118 @@
// terminate this block early or trip the Svelte ESLint parser.
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(() => {
// Register service worker for PWA support
if ('serviceWorker' in navigator) {
if (!('serviceWorker' in navigator)) return;
// Whether this page was already under a worker's control when it loaded.
// The worker calls clients.claim(), so a FIRST-EVER visit also fires
// controllerchange — reloading on that would give every new visitor a
// pointless full page reload. Only an actual hand-over should reload.
const hadController = !!navigator.serviceWorker.controller;
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController || reloading) return;
reloading = true;
location.reload();
});
/** Offer the update, once, for whichever worker is waiting. */
const offer = (worker: ServiceWorker) => {
updateReady = () => {
updateReady = null; // don't leave the prompt up while we hand over
worker.postMessage({ type: 'SKIP_WAITING' });
};
};
navigator.serviceWorker
// NOTE: no `type: 'module'`. SvelteKit/Vite bundles the worker down to a
// classic script with no imports, and registering it as a module made the
// browser install a fresh worker on EVERY register() call — i.e. an
// endless "new version available" prompt that reappeared after each
// refresh, forever.
.register('/service-worker.js')
.then((registration) => {
if (import.meta.env.DEV) {
console.log('✨ Service Worker registered successfully:', registration.scope);
}
// The old code's comment claimed updates were checked periodically, but
// 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));
// A worker may already be waiting from a previous visit. `updatefound`
// does not fire for that one, so without this check the update stays
// invisible and the visitor is stuck on the old version.
if (registration.waiting && hadController) offer(registration.waiting);
// Check for updates periodically
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
if (import.meta.env.DEV) {
console.log('🔄 New service worker available. Refresh to update.');
}
}
if (newWorker.state !== 'installed') return;
// No controller means this is the first install, not an update.
if (!navigator.serviceWorker.controller) return;
offer(newWorker);
});
}
});
})
.catch((error) => {
console.warn('Service Worker registration failed:', error);
});
}
});
</script>
<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 -->
<link rel="icon" href={favicon} />
@@ -100,7 +184,7 @@
<link rel="canonical" href="https://tehriehldeal.com/" />
<meta
name="description"
content="Kevin Riehl — Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript. Explore his experience, projects, and skills in an interactive skill-tree portfolio."
content="Kevin Riehl — Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Explore his experience, projects, and skills in an interactive skill-tree portfolio."
/>
<meta
name="keywords"
@@ -112,11 +196,11 @@
<meta property="og:type" content="website" />
<meta
property="og:title"
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
content="Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC)"
/>
<meta
property="og:description"
content="Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript — shown as an interactive Path of Exile-inspired skill tree."
content="Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Shown as an interactive Path of Exile-inspired skill tree."
/>
<meta property="og:url" content="https://tehriehldeal.com" />
<meta property="og:site_name" content="Atlas of Skills" />
@@ -131,11 +215,11 @@
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
content="Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC)"
/>
<meta
name="twitter:description"
content="Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript — shown as an interactive Path of Exile-inspired skill tree."
content="Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Shown as an interactive Path of Exile-inspired skill tree."
/>
<meta name="twitter:image" content="https://tehriehldeal.com/og-preview.jpg" />
<meta
@@ -150,3 +234,38 @@
</svelte:head>
{@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: var(--z-update-toast);
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>
+35 -4
View File
@@ -1,13 +1,23 @@
<script lang="ts">
import SkillTree from '$lib/components/SkillTree.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>
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
<SeoContent />
<!--
First focusable element on the page: jumps straight to the keyboard tree,
which otherwise sits behind ~20 links in the text version.
-->
<a class="skip-link" href="#tree-nav-label">Skip to skill tree navigator</a>
<main>
<SkillTree />
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
<SeoContent revealed={renderFailed} />
<main class:hidden={renderFailed}>
<SkillTree onRenderFail={() => (renderFailed = true)} />
</main>
<style>
@@ -18,4 +28,25 @@
margin: 0;
padding: 0;
}
main.hidden {
display: none;
}
.skip-link {
position: absolute;
top: -100%;
left: 0.5rem;
z-index: 2000;
padding: 0.6rem 1rem;
border: 1px solid var(--color-gold);
border-radius: 0 0 8px 8px;
background: var(--panel-from);
color: var(--color-gold);
text-decoration: none;
}
.skip-link:focus-visible {
top: 0;
}
</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();
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

-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();
}
});
+1 -1
View File
@@ -10,7 +10,7 @@ const config = {
pages: 'build',
assets: 'build',
fallback: null,
precompress: false,
precompress: true,
strict: true
})
}