docs: add approved portfolio design and plans

This commit is contained in:
2026-09-02 00:11:34 +03:00
commit 33d63a8513
8 changed files with 3143 additions and 0 deletions

View File

@@ -0,0 +1,477 @@
# 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.