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
+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();
|
||||
|
||||
Reference in new issue
Block a user