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>
19 KiB
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 callsmountCredits(). 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
- Content folder missing or partial (no
CONTENT_DIR, or a song without.lrc, or.txtlyrics) → the app still navigates; Listen shows a clear note; nothing throws. Test in Task 4. - No WebGL (
canvas.getContextreturns null) → frieze + panel + timeline still work and a note explains. Test in Task 7. - 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. - 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.
- 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'ssagaTrack1..n; everymotifIds/part motif exists inmotifsand every motif is used ≥2 songs;storyTime.yearsAfterTroynon-decreasing by track; landing mapping as pinned;fanWorksis[];prevNextWithMotifon a motif's middle occurrence returns both neighbours; if/home/user/epic-the-musical-content/Songs and Lyricsexists,scanLibrarytitles (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 testandbun 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.cssstays valid? — it becomes../shared/fromsrc/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:/containsdata-fan-creditsandid="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 viastateToSearch;parseState("?song=nope&tab=x")equalsDEFAULT_STATE;stateToSearch(DEFAULT_STATE) === ""; server tests as above. - Step 2: Run → FAIL.
- Step 3: Implement;
main.tsboots 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 buildpass;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";.txtlyrics →{timed:false};passageStartfinds first keyword paragraph, else 0) → implement → pass → commitfeat(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-stripiff timeline closed; fan empty state text; without content (loadLibraryfalse) Listen shows "Content folder not mounted" and no<audio>; long title wraps (nowhite-space: nowrapon.panel__title) → implement → pass → commitfeat(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 → commitfeat(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'sfigures.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===9in saga order;stops().length===40,sstrictly 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 testpass → screenshots of each place, each landing and each frieze → commitfeat(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 —
transitionForcases (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 rapidset({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) → commitfeat(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 forsrc/app/),PRODUCT.mdif 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) intoscreenshots/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 withoutCONTENT_DIR); 800×900 narrow. Look at every one. bun test,bun run typecheck,bun run build; rebuild the preview container (command indocs/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.mdbefore any merge request; spec 002 and 003 →status: implemented.