# 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 `href`s, 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** ```ts 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** ```ts 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** ```bash 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`. ```ts 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** ```ts 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** ```ts export interface NavigationController { navigate(id: SectionId, target: HTMLElement): number; noteUserScrollIntent(): number; restore(id: SectionId, target: HTMLElement): number; dispose(): void; } ``` Keep native `href`s. 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** ```bash 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** ```ts 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** ```ts 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** ```ts 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** ```bash 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. ```ts 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** ```ts 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** ```bash 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** ```bash 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. ```ts 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** ```ts 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** ```bash 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** ```bash 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.