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>
197 lines
19 KiB
Markdown
197 lines
19 KiB
Markdown
---
|
||
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`.
|