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>
This commit is contained in:
TehRiehlDealandClaude Opus 5 committed 2026-08-09 01:50:47 -07:00
1 parent ea721ae650
commit aa1cda8245
6 files changed
+541 -15

No files matched your search

+47 -11
View File
@@ -325,24 +325,50 @@ export function attachPanZoom(
* 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) {
export function panToNode(
ctrl: SkillTreeController,
x: number,
y: number,
life: Lifecycle,
options: { scale?: number; duration?: number } = {}
) {
const { app, treeContainer } = ctrl;
const scale = treeContainer.scale.x;
const targetX = app.screen.width / 2 - x * scale;
const targetY = app.screen.height / 2 - y * scale;
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: prefersReducedMotion() ? 0 : 0.4,
ease: 'power2.out',
duration,
ease: 'power2.inOut',
overwrite: 'auto',
onUpdate: () => {
updateParallax(ctrl);
updateLabels(ctrl);
onUpdate: sync,
onComplete: () => {
// Re-baseline parallax so later panning stays consistent.
ctrl.initialTreeX = treeContainer.x;
ctrl.initialTreeY = treeContainer.y;
}
});
gsap.to(treeContainer.scale, {
x: targetScale,
y: targetScale,
duration,
ease: 'power2.inOut',
overwrite: 'auto',
onUpdate: sync
});
});
}
@@ -351,6 +377,9 @@ interface KeyboardOpts {
/** 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;
@@ -384,8 +413,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
return;
}
// While the palette is open it owns the keyboard entirely.
if (opts.isSearchOpen()) 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;
@@ -472,6 +501,13 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
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();