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

+28 -1
View File
@@ -11,11 +11,13 @@
const {
showSkip = false,
onSkip
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');
@@ -28,6 +30,13 @@
{#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}
@@ -36,6 +45,24 @@
{/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);
+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>
+43 -3
View File
@@ -13,6 +13,8 @@
import Hud from './Hud.svelte';
import CoachHint from './CoachHint.svelte';
import SearchPalette from './SearchPalette.svelte';
import JourneyMode from './JourneyMode.svelte';
import { getJourneyStops, findStopIndex } from '$lib/utils/journey';
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
import {
BRANCH_IDS,
@@ -49,6 +51,17 @@
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);
function startJourney(index = 0) {
journeyStartIndex = index;
journeyActive = true;
searchOpen = false;
showCoachHint = false;
closeModal();
}
let skipEntrance: (() => void) | null = null;
function hasVisitedBefore() {
@@ -258,7 +271,9 @@
isModalVisible: () => modalVisible,
isStatsVisible: () => statsPanelVisible,
onOpenSearch: () => (searchOpen = true),
isSearchOpen: () => searchOpen
isSearchOpen: () => searchOpen,
onStartJourney: () => startJourney(0),
isJourneyActive: () => journeyActive
},
life
);
@@ -347,6 +362,15 @@
// mount instead would flag someone who bounced after a second as
// having seen it, and they'd never get it.
markVisited();
// ?tour=peraton deep-links straight into that stop of the journey.
const tourParam = new URLSearchParams(window.location.search).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);
}
}, entrance.durationMs + 300);
life.add(() => clearTimeout(hintTimer));
}
@@ -419,7 +443,21 @@
{/if}
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
<Hud showSkip={introPlaying} onSkip={skipIntro} />
<Hud showSkip={introPlaying} onSkip={skipIntro} onStartJourney={() => startJourney(0)} />
<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}
@@ -430,8 +468,10 @@
}}
/>
<!-- 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}
visible={showCoachHint && !journeyActive}
onDismiss={() => {
showCoachHint = false;
}}
+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();
+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));
}