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:
1 parent
ea721ae650
commit
aa1cda8245
6 files changed
+541
-15
No files matched your search
@@ -11,11 +11,13 @@
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
showSkip = false,
|
showSkip = false,
|
||||||
onSkip
|
onSkip,
|
||||||
|
onStartJourney
|
||||||
}: {
|
}: {
|
||||||
/** True while the intro is still playing. */
|
/** True while the intro is still playing. */
|
||||||
showSkip?: boolean;
|
showSkip?: boolean;
|
||||||
onSkip?: () => void;
|
onSkip?: () => void;
|
||||||
|
onStartJourney?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||||
@@ -28,6 +30,13 @@
|
|||||||
{#if origin.tagline}
|
{#if origin.tagline}
|
||||||
<p class="hud-tagline">{origin.tagline}</p>
|
<p class="hud-tagline">{origin.tagline}</p>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -36,6 +45,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
.hud {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--space-5);
|
top: var(--space-5);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
import Hud from './Hud.svelte';
|
import Hud from './Hud.svelte';
|
||||||
import CoachHint from './CoachHint.svelte';
|
import CoachHint from './CoachHint.svelte';
|
||||||
import SearchPalette from './SearchPalette.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 { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
|
||||||
import {
|
import {
|
||||||
BRANCH_IDS,
|
BRANCH_IDS,
|
||||||
@@ -49,6 +51,17 @@
|
|||||||
let introPlaying = $state(false);
|
let introPlaying = $state(false);
|
||||||
let showCoachHint = $state(false);
|
let showCoachHint = $state(false);
|
||||||
let searchOpen = $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;
|
let skipEntrance: (() => void) | null = null;
|
||||||
|
|
||||||
function hasVisitedBefore() {
|
function hasVisitedBefore() {
|
||||||
@@ -258,7 +271,9 @@
|
|||||||
isModalVisible: () => modalVisible,
|
isModalVisible: () => modalVisible,
|
||||||
isStatsVisible: () => statsPanelVisible,
|
isStatsVisible: () => statsPanelVisible,
|
||||||
onOpenSearch: () => (searchOpen = true),
|
onOpenSearch: () => (searchOpen = true),
|
||||||
isSearchOpen: () => searchOpen
|
isSearchOpen: () => searchOpen,
|
||||||
|
onStartJourney: () => startJourney(0),
|
||||||
|
isJourneyActive: () => journeyActive
|
||||||
},
|
},
|
||||||
life
|
life
|
||||||
);
|
);
|
||||||
@@ -347,6 +362,15 @@
|
|||||||
// mount instead would flag someone who bounced after a second as
|
// mount instead would flag someone who bounced after a second as
|
||||||
// having seen it, and they'd never get it.
|
// having seen it, and they'd never get it.
|
||||||
markVisited();
|
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);
|
}, entrance.durationMs + 300);
|
||||||
life.add(() => clearTimeout(hintTimer));
|
life.add(() => clearTimeout(hintTimer));
|
||||||
}
|
}
|
||||||
@@ -419,7 +443,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
|
<!-- 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
|
<SearchPalette
|
||||||
open={searchOpen}
|
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
|
<CoachHint
|
||||||
visible={showCoachHint}
|
visible={showCoachHint && !journeyActive}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
showCoachHint = false;
|
showCoachHint = false;
|
||||||
}}
|
}}
|
||||||
|
|||||||
+47
-11
@@ -325,24 +325,50 @@ export function attachPanZoom(
|
|||||||
* Centre the view on a node in tree-space. Used by the accessible tree
|
* Centre the view on a node in tree-space. Used by the accessible tree
|
||||||
* navigator so keyboard focus and the canvas stay in sync.
|
* 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 { app, treeContainer } = ctrl;
|
||||||
const scale = treeContainer.scale.x;
|
const targetScale = options.scale ?? treeContainer.scale.x;
|
||||||
const targetX = app.screen.width / 2 - x * scale;
|
const targetX = app.screen.width / 2 - x * targetScale;
|
||||||
const targetY = app.screen.height / 2 - y * scale;
|
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(() => {
|
life.animate(() => {
|
||||||
|
const sync = () => {
|
||||||
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
};
|
||||||
|
|
||||||
gsap.to(treeContainer, {
|
gsap.to(treeContainer, {
|
||||||
x: targetX,
|
x: targetX,
|
||||||
y: targetY,
|
y: targetY,
|
||||||
duration: prefersReducedMotion() ? 0 : 0.4,
|
duration,
|
||||||
ease: 'power2.out',
|
ease: 'power2.inOut',
|
||||||
overwrite: 'auto',
|
overwrite: 'auto',
|
||||||
onUpdate: () => {
|
onUpdate: sync,
|
||||||
updateParallax(ctrl);
|
onComplete: () => {
|
||||||
updateLabels(ctrl);
|
// 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. */
|
/** Cmd/Ctrl-K — open the search palette. */
|
||||||
onOpenSearch: () => void;
|
onOpenSearch: () => void;
|
||||||
isSearchOpen: () => boolean;
|
isSearchOpen: () => boolean;
|
||||||
|
/** 'J' — start the guided career journey. */
|
||||||
|
onStartJourney: () => void;
|
||||||
|
isJourneyActive: () => boolean;
|
||||||
onCloseModal: () => void;
|
onCloseModal: () => void;
|
||||||
isModalVisible: () => boolean;
|
isModalVisible: () => boolean;
|
||||||
isStatsVisible: () => boolean;
|
isStatsVisible: () => boolean;
|
||||||
@@ -384,8 +413,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// While the palette is open it owns the keyboard entirely.
|
// While the palette or the guided journey is open, it owns the keyboard.
|
||||||
if (opts.isSearchOpen()) return;
|
if (opts.isSearchOpen() || opts.isJourneyActive()) return;
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
|
||||||
@@ -472,6 +501,13 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
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.
|
// L forces labels on/off regardless of zoom.
|
||||||
if (e.key === 'l' || e.key === 'L') {
|
if (e.key === 'l' || e.key === 'L') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user