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

19 KiB
Raw Blame History

status
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:

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).

  • 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.
  • Step 2: bun test tests/album.test.ts → FAIL (modules missing).
  • Step 3: Implement the files above.
  • Step 4: bun test and bun run typecheck → all pass.
  • 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:

// 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.

  • 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.
  • Step 2: Run → FAIL.
  • Step 3: Implement; main.ts boots the store, mounts credits, renders a placeholder stage showing the current song title (replaced in Task 7).
  • Step 4: bun test, bun run typecheck, bun run build pass; bun run dev → / and /mockups/ load.
  • 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:

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:

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:

// 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:

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.