feat(content): prefer Greek-name Homer translations; voyage app spec, design record and plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 04:50:11 +01:00
co-authored by Claude Opus 5.5
parent 9a0350c82f
commit c11c2ef079
9 changed files with 372 additions and 8 deletions
+6
View File
@@ -78,3 +78,9 @@ work; committing them would push them to Gitea and bake 1.5 GB into the image. T
## 2026-10-30 — three.js allowed in mockup 17
**Decision:** mockup 17 (Voyage with real content) may use three.js; it iterates on 16. No new dependency.
## 2026-10-30 — Homer in translations with Greek names
**Decision:** the Odyssey and Iliad are served from Butcher & Lang (Gutenberg #1728) and Lang, Leaf &
Myers (#3059) when present, falling back to Butler (#1727, #2199). **Why:** the user disliked Butler's
Roman names (Ulysses, Minerva, Jove); EPIC uses the Greek ones. Both are public domain.
+2 -2
View File
@@ -86,5 +86,5 @@ Small, specific, independently actionable. Remove only when actually done.
- Mockup 14: the cave-mouth mask may repeat as holes during the curtain drop (Chrome, oversized mask
region). Fixed in 15 by shrinking the region; 14 left as is (superseded).
- Homer books: the last book (Odyssey XXIV, 250 paragraphs) also swallows the translator's notes and
index after it. Trim at the notes heading before showing whole books.
- Homer books: with Butler's fallback text, the last book (Odyssey XXIV) also swallows the translator's
notes after it. The preferred Butcher & Lang text does not have this problem.
+13
View File
@@ -364,6 +364,19 @@ and brief one agent from a copy of 16, using `/content/library.json`, `parseLrc`
- **"Threads on the sea"** toggle: keep as is ("works nicely").
- When the timeline is hidden, the motif strip goes **into the Paths tab** (not pinned above the tabs).
### 21. Greek names, not Roman
> "I dont like that Samuel Butler uses Roman names instead, is there a version with original names we
> can download on Project Gutenberg?"
- Yes. Downloaded into the content folder (Butler's files kept as a fallback):
- `The Odyssey (Butcher and Lang).txt`: Gutenberg #1728, S. H. Butcher & Andrew Lang (1879): Odysseus,
Athene, Poseidon, Zeus.
- `The Iliad (Lang, Leaf and Myers).txt`: Gutenberg #3059, Lang, Leaf & Myers: same Greek names.
- Checked by counting names: Butcher & Lang has 718× "Odysseus", 0× "Ulysses"; Pope's and Cowper's
translations also use Roman names.
- The server now prefers these (`src/content/routes.ts`); the app drops the Roman-names note.
## Open questions (in priority order)
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
+65
View File
@@ -0,0 +1,65 @@
---
status: approved
---
# 002 — The voyage app: design record
Approved by the user on 2026-10-30 ("Yes. But listen wit hthe lyrics and watch should probably both be
in the listen & lyrics tab. Threads on the sea toggle works nicely. If hide timeline is off, maybe just
put it into paths or something."). Full history and verbatim quotes: `002-direction-round-notes.md`.
Reference mockups: **17 Voyage with real content** (with **16** and **12** for details). This file is
written so the app can be built without opening the mockups.
## The chosen ideas
1. **A 3D sea voyage is the main experience.** The ship sails Odysseus' route; every song is a stop
along it, in album order. Moving between songs moves the ship.
2. **The Greek frieze is the curtain call between scenes.** A black-figure-on-terracotta frieze band
drops like a curtain at scene changes: at the start (Troy), when entering a new saga, and before a
landing. The frieze's ground line becomes the 3D horizon when it lifts.
3. **Landings are sublevels.** At three places the voyage goes ashore through the frieze into a small 3D
scene: the **Cyclops' cave** (Cyclops Saga), **Athena's fight in God Games** (Wisdom Saga), and
**Odysseus' Ithaca homecoming** (Ithaca Saga). "Back to sea" returns to the voyage.
4. **Look 2, "two worlds".** The world is bright and naturalistic in colour (blue sea, warm sky,
green-ochre islands); the frieze keeps terracotta and black. The UI ties them together with the
frieze's meander band as the top edge of every panel/card/bar and terracotta + black accents on
parchment. "The colors pop."
5. **Stylised, not realistic, 3D.** Toon/flat shading, ink outlines, flat colour-band water with drawn
wave strokes, a small flat sun, graphic silhouettes (ship, island, the black-figure giant).
6. **Readable type.** Inter for all UI and labels, nothing under 13px, panel body ≥15px, minimal
tracking. Cormorant Garamond only for large display titles, lyric lines and frieze inscriptions.
7. **A right-side panel stays open** throughout (collapsible to a tab), following the current song.
Tabs:
- **About**: track, saga, story time, facts, parts and singers, the Odyssey reference with a
"Read the passage" reading view (Greek names: Butcher & Lang).
- **Listen & lyrics**: the player (real audio, seek bar, cover art), the line-synced lyrics with the
current line highlighted and click-to-seek, and **Watch** (YouTube link cards). The "listen with the
lyrics" entry and Watch both live here.
- **Paths**: each of the song's motifs with where it was heard before and where it returns, the
thread detail, the chart; and **the motif strip when the timeline is hidden**.
- **Fan works**: credited, linked fan works per song.
A play button in the panel header starts the song and opens Listen & lyrics.
8. **Paths are always visible.** The **bottom timeline** (the "big horizontal line") shows the songs,
saga dividers, "you are here", and motif threads underneath with knots where a motif plays. It can be
hidden; then the strip lives in the Paths tab. **"Threads on the sea"** is a toggle that draws the
current song's motifs as ink-edged ribbons on the sea (behind the ship solid, ahead dashed, with
merged labels such as "Next: Survive").
9. **Motifs as threads** (chosen over pennants and ink-only in 12): soft coloured threads with knots,
one colour per motif, the same colour everywhere (timeline, panel, ribbons, lyric chips).
10. **Real content from the mounted folder** (spec 003): FLAC playback with seeking, `.lrc` synced
lyrics, the Odyssey passage (Butcher & Lang's translation, which keeps the Greek names). Never committed; the app works without it.
11. **Fan project, credited.** The fan notice and the thanks to Jorge Rivera-Herrans on every screen.
## Deselected, and why
- **02 Vertical story, 05 Score/waveform, 06 Constellation**: "too uninteresting".
- **08 Sea chart + log line, 09 Schematic sea chart**: "doesnt work too well".
- **11's metro-map lines inside plates**: "doesnt hit as well". 11's navigation survives as the idea of
places opening into sublevels; 10's vellum style survives in the chart.
- **Pennants (B) and ink-only (C)** motif treatments in 12: threads chosen "definetly".
- **Look 1 (vellum throughout)**: decent but the UI of 2 and 3 looked better.
- **Look 3 (frieze-led)**: made the site feel unified, but "im leaning a lot more towards 2. The colors
pop a lot more." Its cohesion (frieze accents on menus) was carried into look 2.
- **15's inscription-style labels** (small wide-tracked Cormorant capitals): "very hard to read".
- **Choosing one paths placement**: A/B/C "all have their strengths", so they were combined.
- **Simulated playback and placeholder lyrics**: replaced by the real content.
+196
View File
@@ -0,0 +1,196 @@
---
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/files/1727/1727-h/1727-h.htm#chap{NN}` (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:**
```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.
- [ ] **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:**
```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`.
+71
View File
@@ -0,0 +1,71 @@
---
status: approved
---
# 002 — The voyage app (v1)
Status: approved by user 2026-10-30 (direction approval; the user's standing rule: carry an approved
design through spec → plan → execution without review stops). Design record:
`docs/design/002-voyage-app.md`. Content source: `docs/specs/003-content-mount.md`.
## Intent
Turn the approved mockup direction (17) into the real site at `/`: the whole album (9 sagas, 40 songs)
as a stylised 3D sea voyage with the frieze as the curtain call between scenes, three landing
sublevels, a persistent side panel, and a bottom timeline of motif threads, using the real audio,
synced lyrics and the Odyssey text from the mounted content folder.
## Goals
1. **Routes.** The app is served at `/`. The mockup gallery moves to `/mockups/` (mockups stay at
`/mockups/NN-slug/`). Every page keeps the fan notice and credit.
2. **Data for the whole album.** `src/data/` describes 9 sagas and 40 songs in album order (titles and
track numbers as in the content folder) with: story time (years after Troy + a label), the Odyssey
reference as a **book number and passage keywords** (Butler's prose has no line numbers), singers,
motifs per song (and per part where known), facts, and video links. A curated motif set (musical
motifs and character themes) with stable colours. Anything not certain is marked `// TODO(verify)`.
Fan works: a typed, credited list (title, creator, type, URL, licence/permission note), **empty in
v1** — no invented works.
3. **The voyage.** One 3D world: a route past nine places (Troy, the Cyclops' island, the open sea/storm,
Aeaea, the Underworld, Scylla/the thunder sea, Olympus/the wisdom sea, Charybdis/vengeance, Ithaca).
Each song is a stop on the route; selecting a song sails the ship there. Stylised per the design
record.
4. **Curtain call.** Entering a saga (from the previous saga or by jumping) drops that saga's frieze
band (black-figure on terracotta, the saga's name and its song titles as inscriptions), which then
lifts into the sea with the horizon on the frieze's ground line. Within a saga, songs change without
the curtain. Jumps across several sagas fade through the curtain colour.
5. **Three landings.** Cyclops' cave, the God Games arena (Athena), Ithaca's hall. Each is entered from
its saga's frieze ("Go ashore") or automatically when its song is chosen (Survive; God Games; the
Ithaca songs from The Challenge on — exact song→landing mapping in the plan), and left with
"Back to sea".
6. **Side panel** with About, Listen & lyrics (player + synced lyrics + Watch), Paths (motif detail +
strip when the timeline is hidden + chart of the nine places), Fan works (list or empty state).
Collapsible; header play button.
7. **Bottom timeline** for 40 songs: all sagas visible as groups; the current saga expanded with
titles, other sagas compact; you-are-here; motif threads for the current song's motifs across the
whole album. Hideable (strip moves into Paths). "Threads on the sea" toggle.
8. **Navigation and state.** Previous/next song (buttons, ←/→), click any stop, Play voyage (steps
through songs, silent), deep links (`?song=`, `?tab=`, `?motif=`, `?timeline=`, `?ribbons=`), browser
back/forward restore state. Esc closes the reading view / leaves a landing.
9. **Robustness.** Works without the content folder (no audio/lyrics, a clear note). Works without
WebGL (the frieze and panel still navigate; a note explains). Respects `prefers-reduced-motion`
(crossfades). Keyboard reachable with visible focus. Desktop first (1280–1920 wide); below 900px
the panel becomes a bottom sheet and the timeline compact — usable, not polished.
## Non-goals (v1)
- Bespoke frieze art for every song; per-saga friezes may reuse a shared figure kit.
- Time-aligning parts and motifs to the recordings (needs a content mapping file; see 17's report).
- Collecting real fan works; mobile polish; i18n; removing the mockups.
## Acceptance criteria
- `bun test` covers: the dataset (40 songs matching the content folder's titles and order, every motif
used and defined, story times non-decreasing in album order, every song in exactly one saga),
the route/stop geometry (one stop per song, ordered along the route), URL state round-trip, timeline
layout (40 stops, no title overlaps in the expanded saga), and the `/` and `/mockups/` routes.
- With the content folder mounted, at 1440×900: every saga's frieze, each landing, the panel tabs,
playback with synced lyrics, and the timeline/ribbons render correctly in headless screenshots that
were looked at; without it, the fallback renders.
- `bun run typecheck`, `bun run build` pass; the preview container serves the app at `/`.
- Docs: ARCHITECTURE, DECISIONS, DESIGN.md (the chosen direction as tokens and rules), AGENTS.md, TODO.
+3 -2
View File
@@ -25,8 +25,9 @@ it from a path given by `CONTENT_DIR`, mounted read-only into the container.
audio URL, lyrics URL and whether they are timed), built by scanning the folder.
- `GET /content/files/<path>` — the file at that path inside `CONTENT_DIR`, with the right content
type and HTTP range support so audio can seek. Paths outside `CONTENT_DIR` are refused.
- `GET /content/homer/<odyssey|iliad>/<book>.json` — one book of the Butler text: number, heading,
summary line, paragraphs.
- `GET /content/homer/<odyssey|iliad>/<book>.json` — one book: number, heading, summary line,
paragraphs. Prefers the Greek-names translations (Butcher & Lang's Odyssey, #1728; Lang, Leaf &
Myers' Iliad, #3059) when present in the folder, else Butler's (#1727, #2199).
- Without `CONTENT_DIR` (or with a missing folder), all `/content/*` routes answer 404 and every
other route works as before.
2. **Shared helpers** for clients: an LRC parser (timed lines, sorted, multi-stamp lines, metadata tags
+8 -3
View File
@@ -3,7 +3,12 @@ import { join, resolve, sep } from "node:path";
import { scanLibrary } from "./library";
import { splitBooks, type HomerBook } from "./homer";
const HOMER_FILES = { odyssey: "The Odyssey.txt", iliad: "The Illiad.txt" } as const;
// Preferred first: translations that keep the Greek names (Butcher & Lang, Gutenberg #1728; Lang, Leaf
// & Myers, #3059). Butler's (#1727, #2199) use Roman names and are the fallback.
const HOMER_FILES = {
odyssey: ["The Odyssey (Butcher and Lang).txt", "The Odyssey.txt"],
iliad: ["The Iliad (Lang, Leaf and Myers).txt", "The Illiad.txt"],
} as const;
const HOMER_DIR = "Homers the Odyssey Book";
const notFound = () => new Response("Not found", { status: 404 });
// Bun reports FLAC as audio/x-flac; audio/flac is the registered type.
@@ -12,8 +17,8 @@ const contentType = (file: Blob) => (file.type.startsWith("audio/x-flac") ? "aud
const bookCache = new Map<string, HomerBook[]>();
function homerBooks(dir: string, work: keyof typeof HOMER_FILES): HomerBook[] | null {
const path = join(dir, HOMER_DIR, HOMER_FILES[work]);
if (!existsSync(path)) return null;
const path = HOMER_FILES[work].map((f) => join(dir, HOMER_DIR, f)).find((p) => existsSync(p));
if (!path) return null;
let books = bookCache.get(path);
if (!books) {
books = splitBooks(readFileSync(path, "utf8"));
+8 -1
View File
@@ -84,6 +84,8 @@ beforeAll(() => {
writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".txt"), "plain lyrics");
mkdirSync(join(dir, "Homers the Odyssey Book"));
writeFileSync(join(dir, "Homers the Odyssey Book", "The Odyssey.txt"), BOOK_FIXTURE);
writeFileSync(join(dir, "Homers the Odyssey Book", "The Iliad.txt"), BOOK_FIXTURE.replace("ASSEMBLY.", "ROMAN NAMES."));
writeFileSync(join(dir, "Homers the Odyssey Book", "The Iliad (Lang, Leaf and Myers).txt"), BOOK_FIXTURE.replace("ASSEMBLY.", "GREEK NAMES."));
writeFileSync(join(dir, "outside-secret.txt"), "no");
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
@@ -149,7 +151,12 @@ describe("/content routes", () => {
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ book: 2, summary: "ASSEMBLY." });
expect((await fetch(url(withContent, "/content/homer/odyssey/99.json"))).status).toBe(404);
expect((await fetch(url(withContent, "/content/homer/iliad/1.json"))).status).toBe(404);
});
test("the Greek-names translation is preferred, Butler is the fallback", async () => {
// iliad: both files present → Lang, Leaf and Myers; odyssey: only Butler's file in the fixture
expect(await (await fetch(url(withContent, "/content/homer/iliad/2.json"))).json()).toMatchObject({ summary: "GREEK NAMES." });
expect(await (await fetch(url(withContent, "/content/homer/odyssey/2.json"))).json()).toMatchObject({ summary: "ASSEMBLY." });
});
test("without a content folder every /content route is 404 and the site still works", async () => {