Commit Graph
8 Commits
Author SHA1 Message Date
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 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 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
TehRiehlDealandClaude Opus 4.8 84112d8e42 refactor: extract PixiJS rendering into pixi/ modules
Phase 5 (behavior-preserving): decompose the ~1000-line onMount into focused
modules coordinated by a plain (non-reactive) controller object:
- pixi/types.ts        LineObject / NodeObject / BackgroundLayers
- pixi/controller.ts   shared mutable pan/zoom state + Pixi refs (NOT $state,
                       which would break PixiJS object identity)
- pixi/background.ts   gradient/nebula/stars/dust/vignette + ambient loops
- pixi/lines.ts        plasma-beam connections
- pixi/nodes.ts        origin + regular nodes; collapses the duplicated pointer
                       handlers into one attachNodeInteraction helper
- pixi/interaction.ts  pan/zoom/parallax + keyboard nav (return cleanups)
- pixi/animation.ts    sequenced entrance animation

onMount is now a thin orchestrator. SkillTree.svelte: 1177 -> 274 lines
(2453 at the start of the refactor). Listener passivity, the hasMoved click
guard, eventMode-until-animated ordering, and the R-reset parallax rebaseline
are all preserved.

svelte-check: 0 errors/0 warnings. lint/test/build clean. Verified onMount runs
with no runtime exceptions (background + stats panel render). NOTE: pan/zoom/
node-click/keyboard need a manual real-browser check (headless can't paint nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:23:54 -07:00