Files
Portfolio/docs/superpowers/plans/2026-09-01-portfolio-phase-2-journey.md

20 KiB

Portfolio Phase 2: Journey, Scene, and Audio Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add the persistent chase-view night-drive experience, scroll-reactive route state, section signs, optional audio, and lifecycle-safe controls without changing the semantic reading path.

Architecture: Pure controllers transform native scroll and section geometry into serializable journey snapshots. Vue coordinates state and accessible controls; ordinary Three.js and Web Audio modules consume narrow interfaces and independently own their resources. Reduced motion, Static mode, or initialization failure leaves the Phase 1 document intact.

Tech Stack: Vue 3 Composition API, TypeScript, Three.js, Web Audio API, Vitest, Playwright

Spec: docs/superpowers/specs/2026-09-01-portfolio-journey-design.md

Global Constraints

  • Native scrolling, native anchor hrefs, and browser history remain authoritative.
  • The scene never reads portfolio copy or owns semantic section state.
  • One canvas, one current scene generation, one RAF loop, and one optional audio graph.
  • Three.js loads dynamically only when motion policy permits.
  • The car, road, terrain, sun, signs, particles, and audio are original/procedural.
  • Section signs appear only for eligible forward passive transitions and are aria-hidden.
  • AudioContext creation occurs only inside a direct visitor activation path.
  • Every lifecycle edge is a discriminated transition covered by unit tests.

Task 1: Implement the normative scroll-drive controller

Files:

  • Create: src/journey/types.ts
  • Create: src/journey/scroll-drive-controller.ts
  • Create: src/composables/useScrollDrive.ts
  • Test: tests/unit/scroll-drive-controller.test.ts

Interfaces:

  • Consumes: { scrollY: number; now: number; maxScrollY: number }.

  • Produces: ScrollDriveSnapshot, createScrollDriveController(), and useScrollDrive().

  • Step 1: Write failing formula tests

it('falls back to cruise for an invalid sample', () => {
  const controller = createScrollDriveController();
  expect(controller.sample({ scrollY: NaN, now: 10, maxScrollY: 1000 }))
    .toMatchObject({ driveSpeed: 0.18, acceleration: 0 });
});

it('resets transient velocity after a gap over 250 ms', () => {
  const controller = createScrollDriveController();
  controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
  controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
  expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 }).acceleration).toBe(0);
});

Add literal cases for dt=0, negative dt, Infinity, maximum up/down deltas, zero range, and fixed formula values.

  • Step 2: Run the test to verify failure

Run npm run test:unit -- tests/unit/scroll-drive-controller.test.ts. Expected: FAIL because the controller is absent.

  • Step 3: Implement the pure controller
export interface ScrollDriveSnapshot {
  scrollY: number;
  routeProgress: number;
  direction: -1 | 0 | 1;
  acceleration: number;
  driveSpeed: number;
}

export interface ScrollDriveController {
  sample(input: ScrollDriveSample): ScrollDriveSnapshot;
  reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
}

Use constants 2.5, 90 ms, 420 ms, base 0.18, and gap >250 ms exactly. Direction uses signed document delta; acceleration uses its absolute value.

  • Step 4: Add passive RAF-scheduled Vue integration

useScrollDrive() attaches passive scroll/resize/visual-viewport/visibility listeners on mount, queues at most one RAF, resets after hidden gaps, and removes every listener/RAF on teardown.

  • Step 5: Verify and commit
npm run typecheck
npm run test:unit -- tests/unit/scroll-drive-controller.test.ts
git add src/journey/types.ts src/journey/scroll-drive-controller.ts src/composables/useScrollDrive.ts tests/unit/scroll-drive-controller.test.ts
git commit -m "feat: add deterministic scroll drive controller"

Task 2: Implement active sections, sign latching, and navigation epochs

Files:

  • Create: src/journey/section-controller.ts
  • Create: src/journey/sign-controller.ts
  • Create: src/journey/navigation-controller.ts
  • Create: src/composables/useJourneySections.ts
  • Create: src/composables/useKeyboardNavigation.ts
  • Modify: src/components/SiteNavigation.vue
  • Test: tests/unit/section-controller.test.ts
  • Test: tests/unit/sign-controller.test.ts
  • Test: tests/unit/navigation-controller.test.ts
  • Test: tests/browser/navigation.spec.ts

Interfaces:

  • Consumes: canonical section geometry, visual viewport height, scroll source/delta, and native link activations.

  • Produces: JourneySnapshot, monotonic navigation epoch, one optional SignSnapshot, and exact URL/focus effects.

  • Step 1: Write failing section/sign table tests

Test first/last boundaries, equal headings, the 35% activation line, document-end forcing, late growth, forward transitions, reverse suppression, multi-section jumps, and exact sign phase boundaries at 1.2vh, 0.55vh, 0.15vh, and -0.25vh.

expect(selectActiveSection({ headings, activationY: 350, atDocumentEnd: false })).toBe('experience');
expect(nextSign(signState, { source: 'passive-scroll', deltaY: -2, distanceVh: 0.5 })).toBeNull();
  • Step 2: Implement pure section and sign controllers
export interface SignSnapshot {
  targetId: SectionId;
  phase: 'approach' | 'stabilized' | 'pass';
  progress: number;
}

export interface JourneySnapshot extends ScrollDriveSnapshot {
  activeSectionId: SectionId;
  chapter: EnvironmentChapter;
  sign: SignSnapshot | null;
  arrived: boolean;
}

export type EnvironmentChapter =
  | 'deep-night'
  | 'systems'
  | 'infrastructure'
  | 'builds'
  | 'pre-dawn'
  | 'destination';

Only one latch may exist. Semantic active section and decorative sign target remain separate.

  • Step 3: Write failing navigation tests

Cover different/current fragments, invalid fragments, exactly one/zero history mutation, wheel cancellation, passive replaceState, popstate, initial fragments, native scrollend, three-frame fallback, 1500 ms alignment, and focus only for a still-current explicit epoch.

  • Step 4: Implement navigation ownership
export interface NavigationController {
  navigate(id: SectionId, target: HTMLElement): number;
  noteUserScrollIntent(): number;
  restore(id: SectionId, target: HTMLElement): number;
  dispose(): void;
}

Keep native hrefs. Prevent default only after target validation. Wheel, touchmove, Page Up/Down, Home/End, Space, and arrow-key scroll intent cancel older settle/focus work.

  • Step 5: Add measured Vue integration and browser coverage

Measure headings after fonts, ResizeObserver, media dimensions, orientation, and visual-viewport changes. Browser tests assert URLs, focus, back/forward, direct fragments, and no focus theft from manual scrolling.

  • Step 6: Verify and commit
npm run test:unit -- tests/unit/section-controller.test.ts tests/unit/sign-controller.test.ts tests/unit/navigation-controller.test.ts
npm run test:browser -- tests/browser/navigation.spec.ts
git add src/journey src/composables/useJourneySections.ts src/composables/useKeyboardNavigation.ts src/components/SiteNavigation.vue tests/unit tests/browser/navigation.spec.ts
git commit -m "feat: coordinate sections signs and native navigation"

Task 3: Implement motion policy and adaptive quality

Files:

  • Create: src/journey/motion-policy.ts
  • Create: src/journey/adaptive-quality.ts
  • Create: src/composables/useMotionPreferences.ts
  • Create: src/composables/useAdaptiveQuality.ts
  • Test: tests/unit/motion-policy.test.ts
  • Test: tests/unit/adaptive-quality.test.ts

Interfaces:

  • Consumes: system preference, saved pause/quality, viewport/pointer class, visibility, scene state, and eligible frame intervals.

  • Produces: MotionPolicy, maximum/actual QualityTier, and downgrade decisions.

  • Step 1: Write failing precedence and initial-tier tests

expect(resolveMotionPolicy({ systemReduce: true, savedPause: false, savedStatic: false })).toMatchObject({
  allowSceneRequest: false,
  maximumTier: 'static'
});
expect(initialMaximum({ width: 390, coarsePointer: true })).toBe('low');
expect(initialMaximum({ width: 1440, coarsePointer: false })).toBe('high');
  • Step 2: Implement the motion policy
export type QualityTier = 'high' | 'medium' | 'low' | 'static';
export interface MotionPolicy {
  allowSceneRequest: boolean;
  allowAutomaticVideo: boolean;
  maximumTier: QualityTier;
  reason: 'full' | 'system-reduce' | 'visitor-pause' | 'saved-static' | 'data-saver';
}

Persist only pause, maximum quality, and volume.

  • Step 3: Write failing adaptive epoch tests

Cover two-second warm-up, five-second/120-sample windows, nearest-rank p95, 20/25/40 ms thresholds, two bad Low windows, >250 ms same-epoch catastrophic interval, hidden/pause gap exclusion, 30-visible-second cooldown, resize reset, and no automatic upgrade.

  • Step 4: Implement the adaptive controller
export interface AdaptiveQualityController {
  enterRunningEpoch(now: number): void;
  leaveRunningEpoch(): void;
  sampleFrame(start: number, end: number): QualityDecision | null;
  setMaximum(tier: QualityTier, reason: 'resize' | 'manual'): QualityDecision | null;
  snapshot(): AdaptiveQualitySnapshot;
}

export interface QualityDecision {
  previous: QualityTier;
  next: QualityTier;
  reason: 'catastrophic-frame' | 'p95-window' | 'two-bad-low-windows' | 'maximum-change';
}

export interface AdaptiveQualitySnapshot {
  maximum: QualityTier;
  actual: QualityTier;
  epoch: number | null;
  coolingDownUntil: number | null;
  eligibleSamples: number;
  consecutiveBadLowWindows: number;
}

Every epoch exit discards its RAF baseline and incomplete window.

  • Step 5: Verify and commit
npm run test:unit -- tests/unit/motion-policy.test.ts tests/unit/adaptive-quality.test.ts
git add src/journey/motion-policy.ts src/journey/adaptive-quality.ts src/composables/useMotionPreferences.ts src/composables/useAdaptiveQuality.ts tests/unit
git commit -m "feat: add motion policy and adaptive quality"

Task 4: Build the lifecycle-safe Three.js engine

Files:

  • Create: src/scene/types.ts
  • Create: src/scene/resources.ts
  • Create: src/scene/create-scene-controller.ts
  • Create: src/scene/world/road.ts
  • Create: src/scene/world/terrain.ts
  • Create: src/scene/world/sun.ts
  • Create: src/scene/world/car.ts
  • Create: src/scene/world/sign.ts
  • Create: src/scene/world/particles.ts
  • Create: src/scene/world/environment.ts
  • Create: src/scene/render-quality.ts
  • Test: tests/unit/scene-lifecycle.test.ts
  • Test: tests/unit/scene-resources.test.ts

Interfaces:

  • Consumes: canvas, JourneySnapshot, QualityTier, and abort signal.

  • Produces: SceneController and observable SceneLifecycleState without exposing Three.js objects.

  • Step 1: Write failing lifecycle tests with fake resources

Cover every legal/illegal edge, generation invalidation, abort during loading, exactly-once disposal, context loss during loading/running/retained pause, hidden restore, five-second cutoff, bfcache freeze/restore, and terminal disposal.

expect(() => machine.transition('disposed', 'running')).toThrow(/illegal transition/i);
expect(fakeRenderer.dispose).toHaveBeenCalledTimes(1);
expect(snapshot.canvasCount).toBe(1);
expect(snapshot.rafCount).toBeLessThanOrEqual(1);
  • Step 2: Define state and resource ownership
export type SceneLifecycleState =
  | { kind: 'idle' }
  | { kind: 'loading'; generation: number }
  | { kind: 'running'; generation: number }
  | { kind: 'paused'; generation: number | null; resumeNeedsLoad: boolean }
  | { kind: 'lost'; lostAt: number; restorationPending: boolean }
  | { kind: 'restoring'; generation: number; lostAt: number }
  | { kind: 'static'; reason: string }
  | { kind: 'disposed' };

SceneResourceBag.dispose() owns geometries, materials, textures, targets, passes, renderer, listeners, observers, timers, and RAF exactly once.

  • Step 3: Implement the procedural world

Road markers scroll from drive distance/speed; restrained wire terrain frames the road; sun/haze evolve by chapter; the original non-branded car uses custom/primitive geometry with a continuous rear light; signs render canonical route labels to internal textures; particles use fixed pools capped by tier.

  • Step 4: Implement quality and renderer ownership

High/Medium/Low alter particle count, terrain segments, post-processing, and pixel ratio. createSceneController() owns one renderer/canvas binding and RAF. update() copies a serializable snapshot and never queries content DOM.

  • Step 5: Implement context/page lifecycle behavior

Attach context loss/restore, visibility, pagehide, and pageshow through the resource bag. Check generation and overlay before every async attach. Late/stale completions dispose themselves.

  • Step 6: Verify and commit
npm run typecheck
npm run test:unit -- tests/unit/scene-lifecycle.test.ts tests/unit/scene-resources.test.ts
git add src/scene tests/unit/scene-lifecycle.test.ts tests/unit/scene-resources.test.ts
git commit -m "feat: build procedural lifecycle-safe drive scene"

Task 5: Integrate scene, HUD, poster, and motion controls

Files:

  • Create: src/components/SceneCanvas.vue
  • Create: src/components/JourneyHud.vue
  • Create: src/components/DriveControls.vue
  • Create: src/composables/useSceneController.ts
  • Create: src/styles/world.css
  • Modify: src/components/AppShell.vue
  • Modify: src/App.vue
  • Test: tests/unit/SceneCanvas.test.ts
  • Test: tests/browser/scene-controls.spec.ts

Interfaces:

  • Consumes: journey snapshot, motion policy, quality controller, scene factory.

  • Produces: fixed decorative world, accessible controls, poster fallback, status text.

  • Step 1: Write failing lazy-load/control tests

Assert no Three.js request in reduced motion/Static, exactly one canvas in full motion, visible Pause motion, quality maximum behavior, canvas absent from the accessibility tree, and a usable poster after failure.

  • Step 2: Implement dynamic scene ownership

useSceneController() imports the scene only when allowSceneRequest is true, binds abort/generation tokens, and disposes once on unmount, non-persisted pagehide, or Static transition.

  • Step 3: Implement scene host and HUD

Expose one visually hidden scene description from content. Canvas, car, signs, and visual HUD are aria-hidden. The poster has intrinsic dimensions and empty alt because the adjacent description owns meaning. HUD reads route percentage/current section/quality and ARRIVED at Contact.

  • Step 4: Implement controls

Use real buttons and a named native quality control. Exact names are Pause motion, Resume motion, Enable motion, and Quality. Static/unavailable state uses visible text and not color alone.

  • Step 5: Verify and commit
npm run test:unit -- tests/unit/SceneCanvas.test.ts
npm run test:browser -- tests/browser/scene-controls.spec.ts
git add src/components src/composables/useSceneController.ts src/styles/world.css src/App.vue tests
git commit -m "feat: integrate accessible journey world and controls"

Task 6: Build the opt-in generated audio engine

Files:

  • Create: src/audio/types.ts
  • Create: src/audio/create-audio-controller.ts
  • Create: src/audio/synth-engine.ts
  • Create: src/composables/useAudioEngine.ts
  • Modify: src/components/DriveControls.vue
  • Test: tests/unit/audio-lifecycle.test.ts
  • Test: tests/browser/audio-controls.spec.ts

Interfaces:

  • Consumes: direct activation, volume/mute input, smoothed drive intensity, visibility/lifecycle events.

  • Produces: AudioController and AudioUiState; Vue never receives AudioContext or nodes.

  • Step 1: Write failing audio lifecycle/race tests

Cover idle/start/running/suspended/user-required/failed/disposed, double activation, quick hide/show while starting, interruption while starting, mute/volume, one graph, rejection recovery, bfcache, and exactly-once close.

expect(fakeContext.constructed).toBe(0);
await controller.startFromGesture();
expect(fakeContext.constructed).toBe(1);
await controller.startFromGesture();
expect(fakeContext.constructed).toBe(1);
  • Step 2: Implement idempotent synthesis and state
export type AudioUiState =
  | { kind: 'idle' }
  | { kind: 'starting' }
  | { kind: 'running'; muted: boolean; volume: number }
  | { kind: 'suspended'; resumeRequiresGesture: boolean }
  | { kind: 'failed'; message: string }
  | { kind: 'disposed' };

Create a restrained generated engine/road bed from oscillators, filtered noise, gain, and dynamics. Default volume is 0.4; clamp [0,1]; smooth intensity; create/connect nodes once per generation.

  • Step 3: Integrate accessible controls

Before activation show START THE DRIVE and Enables the interactive soundtrack. Afterwards show Mute sound/Unmute sound and a visible native range named Sound volume. Failures show concise recovery text and never obscure content.

  • Step 4: Verify direct-gesture creation and commit
npm run test:unit -- tests/unit/audio-lifecycle.test.ts
npm run test:browser -- tests/browser/audio-controls.spec.ts
git add src/audio src/composables/useAudioEngine.ts src/components/DriveControls.vue tests
git commit -m "feat: add opt-in procedural drive audio"

Task 7: Lock journey integration and static behavior

Files:

  • Create: tests/browser/journey-integration.spec.ts
  • Create: tests/browser/reduced-motion.spec.ts
  • Create: tests/browser/static-fallback.spec.ts
  • Create: tests/browser/bfcache-lifecycle.spec.ts
  • Create: tests/helpers/scene-instrumentation.ts
  • Modify: scripts/verify-artifacts.mjs

Interfaces:

  • Consumes: completed Phase 2 app.

  • Produces: release-blocking synchronized-state and resource-count assertions.

  • Step 1: Test one-source journey synchronization

Scroll all sections and assert active navigation, HUD, route progress, environment chapter, and Contact arrival update from one snapshot within one RAF. Visible signs must match the independent latch oracle.

  • Step 2: Test reduced motion before boot

Emulate reduced motion, clear storage, load, and assert no Three.js request, canvas, automatic video source, or sign; require poster, semantic HUD, navigation, and contact.

  • Step 3: Test Static and failure fallback

Block the scene chunk and inject shader/context failures. Assert one poster, zero scene RAF, no unhandled rejection, and full navigation/contact. Enable motion retries only when system reduction is absent.

  • Step 4: Test freeze/restore counts

Exercise persisted pagehide/pageshow across idle/loading/running/paused/lost/restoring/static. Assert at most one current generation, canvas, RAF loop, and audio graph.

  • Step 5: Run the phase gate and commit
npm run typecheck
npm run test:unit
npm run build:preview
npm run test:browser -- tests/browser/journey-integration.spec.ts tests/browser/reduced-motion.spec.ts tests/browser/static-fallback.spec.ts tests/browser/bfcache-lifecycle.spec.ts
git add tests/browser tests/helpers/scene-instrumentation.ts scripts/verify-artifacts.mjs
git commit -m "test: lock journey lifecycle and static fallback"

Phase 2 completion checkpoint

Review desktop, tablet, mobile, reduced-motion, and Static captures. Approve only if the world is memorable while the information layer remains quiet, professional, readable, and dominant enough for hiring decisions.