Files
Epic-The-Musical-Timeline/docs/plans/002-voyage-app.md
T
Lucas WintherandClaude Opus 5.5 288425501d feat(data): album data for all 9 sagas and 40 songs
Odyssey links point at Butcher & Lang (#1728), whose Greek names the
keywords were checked against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:56:32 +01:00

197 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: in-progress
---
# The Voyage App (v1) 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:** Ship the approved voyage design (mockup 17 + the user's last changes) as the real app at `/` for all 9 sagas / 40 songs.
**Architecture:** A new page `src/app/` (plain TS + DOM + three.js, bundled by Bun's HTML import) with pure, unit-tested modules for state, route geometry, transitions and timeline layout, and DOM/three modules ported from `src/mockups/17-voyage-real/`. New album data lives in `src/data/album/` so the mockups' `src/data/types.ts`/`sample.ts` stay untouched. Real content comes from `/content/*` (spec 003).
**Tech Stack:** Bun 1.3.14 (serve, bundler, test + happy-dom), TypeScript, three.js 0.186, @fontsource Inter + Cormorant Garamond.
**Spec:** `docs/specs/002-voyage-app.md` · design record `docs/design/002-voyage-app.md` · content `docs/specs/003-content-mount.md`
## Global Constraints
- Every page keeps `<footer data-fan-credits>`, links `../shared/credits.css` (path relative to the page) and calls `mountCredits()`. Never write notice text.
- Real lyrics/audio/art only from `/content/*` at runtime. Never put lyric text in source, tests, docs or commit messages.
- Fonts/three.js bundled from npm; no CDN; no new dependencies.
- Inter for UI/labels, **nothing under 13px**, panel body ≥15px; Cormorant only for big titles, lyric lines, frieze inscriptions.
- Look 2 palette: bright world, terracotta + black frieze, parchment UI with the meander band on top of panels/cards/bars.
- Facts you cannot confirm get `// TODO(verify)` (album data only).
- Mockup folders `src/mockups/**` are not modified (copy code from 17 instead).
- Desktop 1440×900 is the reference; below 900px: panel as bottom sheet, compact timeline.
## Review Focus
1. **Content folder missing or partial** (no `CONTENT_DIR`, or a song without `.lrc`, or `.txt` lyrics) → the app still navigates; Listen shows a clear note; nothing throws. Test in Task 4.
2. **No WebGL** (`canvas.getContext` returns null) → frieze + panel + timeline still work and a note explains. Test in Task 7.
3. **Deep link to any of 40 songs / bad `?song=`** → valid songs open directly (landing songs open in their landing); unknown ids fall back to the first song. Test in Task 2.
4. **Rapid navigation** (holding → or clicking stops during a transition) → transitions are cancelled/queued, the final state matches the last request, no stuck curtain. Test in Task 7.
5. **Long titles** ("Would You Fall in Love with Me Again", "Not Sorry for Loving You") → no overlap in the expanded timeline saga, panel title wraps. Test in Task 5.
---
### Task 1: Album data
**Files:**
- Create: `src/data/album/types.ts`, `src/data/album/motifs.ts`, `src/data/album/songs.ts`, `src/data/album/sagas.ts`, `src/data/album/query.ts`, `src/data/album/index.ts`
- Test: `tests/album.test.ts`
**Interfaces — Produces:**
```ts
export type LandingId = "cave" | "arena" | "hall";
export type PlaceId = "troy" | "cyclops" | "ocean" | "circe" | "underworld" | "thunder" | "wisdom" | "vengeance" | "ithaca";
export interface Motif { id: string; name: string; kind: "motif" | "character"; color: string; description: string }
export interface AlbumSaga { id: PlaceId; name: string; order: number; year: number; place: string; blurb: string }
export interface OdysseyRef { book: number; keywords: string[]; url: string } // url: Gutenberg #1727 book anchor
export interface AlbumPart { name: string; singers: string[]; motifIds: string[] }
export interface AlbumSong {
id: string; title: string; sagaId: PlaceId; track: number /*1..40 album order*/; sagaTrack: number;
storyTime: { yearsAfterTroy: number; label: string }; odyssey: OdysseyRef | null;
singers: string[]; motifIds: string[]; parts: AlbumPart[]; facts: string[];
videos: { title: string; url: string }[]; landing: LandingId | null;
}
export interface FanWork { songId: string; type: "art" | "animatic" | "cover" | "cosplay" | "other"; title: string; creator: string; url: string; permission: string }
// index.ts re-exports: sagas, songs, motifs, fanWorks (= [] in v1), and query helpers:
export function songById(id: string): AlbumSong | undefined;
export function songsInSaga(sagaId: PlaceId): AlbumSong[];
export function sagaOf(song: AlbumSong): AlbumSaga;
export function songsWithMotif(motifId: string): AlbumSong[];
export function prevNextWithMotif(song: AlbumSong, motifId: string): { prev: AlbumSong | null; next: AlbumSong | null };
```
Values pinned by the spec: titles and order exactly as the content folder (Troy: The Horse and the Infant, Just a Man, Full Speed Ahead, Open Arms, Warrior of the Mind · Cyclops: Polyphemus, Survive, Remember Them, My Goodbye · Ocean: Storm, Luck Runs Out, Keep Your Friends Close, Ruthlessness · Circe: Puppeteer, Wouldn’t You Like, Done For, There Are Other Ways · Underworld: The Underworld, No Longer You, Monster · Thunder: Suffering, Different Beast, Scylla, Mutiny, Thunder Bringer · Wisdom: Legendary, Little Wolf, We'll Be Fine, Love in Paradise, God Games · Vengeance: Not Sorry for Loving You, Dangerous, Charybdis, Get in the Water, Six Hundred Strike · Ithaca: The Challenge, Hold Them Down, Odysseus, I Can't Help but Wonder, Would You Fall in Love with Me Again). Landings: `cave` = Polyphemus, Survive; `arena` = God Games; `hall` = all five Ithaca songs. Ids are kebab-case titles (`wouldnt-you-like`). Motif colours: reuse `src/data/sample.ts` colours for motifs that exist there. Odyssey `url`: `https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap{NN}` (Butcher & Lang) (two digits).
Content guidance: use what is well established about EPIC (motifs such as Warrior of the Mind, Open Arms, Just a Man/My Goodbye, Ruthlessness, Monster, Little Wolf, Love in Paradise/Penelope's theme, Thunder Bringer, character themes for Odysseus, Athena, Poseidon, Zeus, Penelope, Telemachus, Polyphemus, Circe, Hermes…). The `.lrc` files in `/home/user/epic-the-musical-content` may be *read* to check where phrases recur, but no lyric text may be written anywhere. Mark every uncertain value `// TODO(verify)`.
- [x] **Step 1: Write failing tests** in `tests/album.test.ts`: 9 sagas ordered troy…ithaca; `songs.length === 40`; tracks 1..40 contiguous; `songs.map(s=>s.title)` equals the pinned list; each saga's `sagaTrack` 1..n; every `motifIds`/part motif exists in `motifs` and every motif is used ≥2 songs; `storyTime.yearsAfterTroy` non-decreasing by track; landing mapping as pinned; `fanWorks` is `[]`; `prevNextWithMotif` on a motif's middle occurrence returns both neighbours; if `/home/user/epic-the-musical-content/Songs and Lyrics` exists, `scanLibrary` titles (normalised: lowercase, strip non-alphanumerics) equal the album titles in order.
- [x] **Step 2:** `bun test tests/album.test.ts` → FAIL (modules missing).
- [x] **Step 3:** Implement the files above.
- [x] **Step 4:** `bun test` and `bun run typecheck` → all pass.
- [x] **Step 5:** Commit `feat(data): album data for all 9 sagas and 40 songs`.
### Task 2: App shell, routes and URL state
**Files:**
- Create: `src/app/index.html`, `src/app/main.ts`, `src/app/style.css` (tokens: colours, type, meander band, layout grid: stage | panel 420px, timeline bottom), `src/app/state.ts`
- Move: `src/index.html`, `src/index.ts`, `src/index.css` → `src/mockups/index.html`, `src/mockups/index.ts`, `src/mockups/index.css` (fix relative paths: `../shared/credits.css` → `../shared/credits.css` stays valid? — it becomes `../shared/` from `src/mockups/`; card hrefs stay absolute)
- Modify: `src/server.ts` (`"/"` → app, `"/mockups/"` → gallery, `"/mockups"` → 302 `/mockups/`), `tests/server.test.ts` (gallery tests hit `/mockups/`; new test: `/` contains `data-fan-credits` and `id="voyage-app"`)
- Test: `tests/state.test.ts`
**Interfaces — Produces:**
```ts
// src/app/state.ts
export type Tab = "about" | "listen" | "paths" | "fan";
export interface AppState { songId: string; tab: Tab; motif: string | null; timeline: "open" | "closed"; ribbons: boolean; read: boolean }
export const DEFAULT_STATE: AppState; // songId "the-horse-and-the-infant", tab "about", motif null, timeline "open", ribbons false, read false
export function parseState(search: string): AppState; // unknown song/tab/motif → defaults
export function stateToSearch(s: AppState): string; // omits defaults; "" for DEFAULT_STATE
export function createStore(initial: AppState): { get(): AppState; set(p: Partial<AppState>, opts?: { push?: boolean }): void; subscribe(fn: (s: AppState, prev: AppState) => void): () => void };
// store.set writes history (push for song changes, replace otherwise); popstate restores.
```
`index.html` has `<main id="voyage-app">` with regions `#stage` (canvas + frieze layer + caption), `#panel`, `#timeline`, and the credits footer.
- [x] **Step 1:** Failing tests: `parseState("?song=survive&tab=listen&motif=polyphemus&timeline=closed&ribbons=1&read=1")` round-trips via `stateToSearch`; `parseState("?song=nope&tab=x")` equals `DEFAULT_STATE`; `stateToSearch(DEFAULT_STATE) === ""`; server tests as above.
- [x] **Step 2:** Run → FAIL.
- [x] **Step 3:** Implement; `main.ts` boots the store, mounts credits, renders a placeholder stage showing the current song title (replaced in Task 7).
- [x] **Step 4:** `bun test`, `bun run typecheck`, `bun run build` pass; `bun run dev` → `/` and `/mockups/` load.
- [x] **Step 5:** Commit `feat(app): app shell at /, gallery at /mockups/, URL state`.
### Task 3: Content client
**Files:** Create `src/app/content.ts` (port of `src/mockups/17-voyage-real/content.ts` to album types). Test: `tests/app-content.test.ts`.
**Interfaces — Produces:**
```ts
export function loadLibrary(timeoutMs?: number): Promise<boolean>; // false when /content 404s or times out (default 4000)
export function realFor(song: AlbumSong): { audio: string; cover: string | null; lyrics: { url: string; timed: boolean } | null } | null;
export type Lyrics = { timed: true; lines: LrcLine[] } | { timed: false; lines: string[] };
export function loadLyrics(ref: { url: string; timed: boolean }): Promise<Lyrics | null>;
export function loadBook(book: number): Promise<HomerBook | null>;
export function passageStart(book: HomerBook, keywords: string[]): { index: number; word: string | null };
```
- [ ] Steps: failing tests with a mocked `fetch` (library 404 → `false`; match by normalised title incl. curly apostrophe "Wouldn’t You Like"; `.txt` lyrics → `{timed:false}`; `passageStart` finds first keyword paragraph, else 0) → implement → pass → commit `feat(app): content client`.
### Task 4: Side panel
**Files:** Create `src/app/panel/panel.ts`, `about.ts`, `listen.ts` (player + synced lyrics + Watch), `paths.ts`, `fanworks.ts`, `reading.ts`; styles appended to `src/app/style.css` under a `/* panel */` section. Port from 17's `panel.ts`, `player.ts`. Test: `tests/panel.test.ts` (happy-dom).
**Interfaces:**
- Consumes: Task 1 album API, Task 2 store, Task 3 content client.
- Produces: `mountPanel(root: HTMLElement, store: Store, opts: { timelineHidden: () => boolean }): { refresh(): void; destroy(): void }`.
Behaviour pinned by the user: **Listen & lyrics** contains the player, the lyrics and **Watch** (video cards via `createVideoCards`); About contains no Watch and no listen button. **Paths** shows the motif strip at its top **only when `state.timeline === "closed"`**, then per-motif "heard before / here / returns" rows, then the nine-place chart. **Fan works**: empty state "No fan works collected yet. Real ones will be credited and linked to their creators." when `fanWorks` has none for the song. Header play button: starts audio and switches to `listen`. Reading view (`state.read`) shows the Odyssey book (Butcher & Lang, Greek names — no Roman-names note) from the passage start, "Show the N paragraphs before", Back.
- [ ] Steps: failing tests — About has no `.watch`; Listen has `.player`, `.lyrics`, `.watch`; Paths has `.motif-strip` iff timeline closed; fan empty state text; without content (`loadLibrary` false) Listen shows "Content folder not mounted" and no `<audio>`; long title wraps (no `white-space: nowrap` on `.panel__title`) → implement → pass → commit `feat(app): side panel`.
### Task 5: Timeline
**Files:** Create `src/app/timeline/layout.ts` (pure), `src/app/timeline/timeline.ts` (DOM/SVG). Test: `tests/timeline.test.ts`.
**Interfaces — Produces:**
```ts
export interface TimelineStop { songId: string; x: number; label: string | null; current: boolean }
export interface TimelineLayout { stops: TimelineStop[]; sagas: { sagaId: PlaceId; x0: number; x1: number; expanded: boolean }[]; width: number }
export function layoutTimeline(currentSongId: string, width: number, measure: (text: string) => number): TimelineLayout;
export function mountTimeline(root: HTMLElement, store: Store): { refresh(): void };
```
Layout rule: the current saga is expanded (labels, spacing by measured label width + 16px gap, min 64px); other sagas are compact (no labels, 18px per stop, saga name above); remaining width distributed to gaps between sagas; if it still doesn't fit, the expanded saga scrolls into view (the strip scrolls horizontally). Threads: one row per motif of the current song, knots at songs that use it, across all 40. Hide/show control writes `state.timeline`. "Threads on the sea" switch writes `state.ribbons`.
- [ ] Steps: failing tests — 40 stops; stops strictly increasing x; labels only in the current saga; in every saga expanded (try each saga's first song, width 980, measure = 7.2px/char) no two labelled stops overlap; collapse sets `timeline=closed` → implement → pass → commit `feat(app): bottom timeline`.
### Task 6: The 3D world, landings and friezes
**Files:**
- Create `src/app/world/route.ts` (pure), `world.ts`, `materials.ts`, `ship.ts`, `islands.ts`, `ribbons.ts`, `landings/cave.ts`, `landings/arena.ts`, `landings/hall.ts`
- Create `src/app/frieze/kit.ts` (figure primitives from 17's `figures.ts`), `src/app/frieze/sagas.ts`, `src/app/frieze/curtain.ts`
- Test: `tests/route.test.ts`, `tests/frieze.test.ts`
**Interfaces — Produces:**
```ts
// route.ts
export interface Place { id: PlaceId; x: number; z: number; radius: number }
export const PLACES: Place[]; // 9, in saga order, along a winding route
export interface Stop { songId: string; placeId: PlaceId; x: number; z: number; heading: number; s: number /* 0..1 along route */ }
export function stops(): Stop[]; // 40, s strictly increasing
// world.ts
export interface World {
sailTo(songId: string, opts?: { instant?: boolean }): Promise<void>;
setLanding(id: LandingId | null): Promise<void>;
setRibbons(motifIds: string[] | null, highlight: string | null): void;
horizonFromFrieze(groundYpx: number): void; // camera pitch so horizon sits on the frieze ground line
resize(): void; dispose(): void;
}
export function createWorld(canvas: HTMLCanvasElement): World | null; // null when WebGL is unavailable
// frieze
export function friezeSvg(sagaId: PlaceId): string; // saga name + its song titles as inscriptions
export function createCurtain(layer: HTMLElement): { drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>; lift(): Promise<void>; fade(): Promise<void>; groundY(): number };
```
Style per the design record (toon/ink, flat bands, small sun). Each place gets a distinct stylised silhouette (Troy walls; Cyclops' island + cave mouth; storm spiral at sea; Aeaea with a palace; Underworld dark gate; thunder sea with Scylla's cliffs; Olympus peak; Charybdis whirlpool; Ithaca with palace). Landings: cave (port 17's), arena (Athena vs. Odysseus as black-figure cut-outs on a sandy ring with columns), hall (Ithaca hall with a bow and a hearth). Friezes: one per saga from the shared kit; reuse 17's Troy and Cyclops friezes.
- [ ] Steps: failing tests — `PLACES.length===9` in saga order; `stops().length===40`, `s` strictly increasing, each stop within 1.5×radius of its place; `friezeSvg(id)` contains the saga name and every song title of that saga (escaped) for all 9 → implement → `bun test` pass → screenshots of each place, each landing and each frieze → commit `feat(app): 3D world, landings and friezes`.
### Task 7: Voyage controller and integration
**Files:** Create `src/app/voyage.ts` (pure `transitionFor`) and `src/app/controller.ts`; modify `src/app/main.ts`. Test: `tests/voyage.test.ts`.
**Interfaces — Produces:**
```ts
export type Transition = { kind: "sail" } | { kind: "curtain"; sagaId: PlaceId } | { kind: "fade" } ;
export interface Step { transition: Transition; landing: LandingId | null }
export function transitionFor(fromSongId: string, toSongId: string): Step;
// same saga → sail; adjacent saga → curtain(to's saga); ≥2 sagas apart → fade; landing = to.landing.
```
Controller: subscribes to the store; runs `transitionFor` → curtain/fade → `world.sailTo` → `world.setLanding`; a newer request cancels the running one (generation counter) and the last request wins; Play voyage advances every 3.4s (silent) and stops on user input; ←/→ prev/next song; Esc closes reading view, else leaves a landing; caption card shows saga · story time and song title; no WebGL → `createWorld` null → frieze of the current saga stays as the stage with a note "3D needs WebGL — browsing with the frieze"; `prefers-reduced-motion` → every transition is a 200ms crossfade.
- [ ] Steps: failing tests — `transitionFor` cases (polyphemus→survive sail + cave; full-speed-ahead→polyphemus? (troy→cyclops adjacent) curtain cyclops; storm→god-games fade + arena; survive→remember-them sail + null); controller with a fake world: five rapid `set({songId})` calls end with the world at the last song and the curtain lifted; no-WebGL path renders the note → implement → pass → full-app screenshots (below) → commit `feat(app): voyage controller`.
### Task 8: Docs, verification, deploy
- [ ] `DESIGN.md`: replace "visual direction open" with the chosen direction as tokens and rules (palette, type scale, meander band, motif colours from data, 3D style, panel/timeline layout).
- [ ] `docs/ARCHITECTURE.md` (app modules, routes `/` and `/mockups/`, album data), `docs/DECISIONS.md` (album data separate from mockup data; landing mapping; silent Play voyage; fan works empty in v1), `AGENTS.md` (overview line, architecture, rules for `src/app/`), `PRODUCT.md` if it describes the site as mockups, `docs/TODO.md` (parts/motif time alignment, mobile polish, fan-work sourcing, Odyssey passage confirmation).
- [ ] Screenshots at 1440×900 (`--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader --virtual-time-budget=6000`) into `screenshots/app-*.png`: first load; one song per saga; each landing; Listen playing with lyrics; Paths with timeline closed; ribbons on; reading view; no content (server without `CONTENT_DIR`); 800×900 narrow. Look at every one.
- [ ] `bun test`, `bun run typecheck`, `bun run build`; rebuild the preview container (command in `docs/design/002-direction-round-notes.md` §7); curl `/`, `/mockups/`, `/content/library.json`.
- [ ] Final whole-branch review by a fresh agent; fix findings; `git rm docs/plans/002-voyage-app.md docs/plans/003-content-mount.md` before any merge request; spec 002 and 003 → `status: implemented`.