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 {
|
||||
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);
|
||||
|
||||
@@ -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 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
@@ -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();
|
||||
|
||||
@@ -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