feat(mockups): slot 17 voyage with real content

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 04:34:35 +01:00
co-authored by Claude Opus 5.5
parent 4ea8049b56
commit 6db056fba2
12 changed files with 111 additions and 4 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# EPIC: The Musical Timeline
Unofficial fan site: an interactive timeline of *EPIC: The Musical* songs. Currently 7 design-direction
mockups. Bun 1.3.14 (server, bundler, test runner), TypeScript, three.js (mockups 06/07/13–16 only), Docker.
mockups. Bun 1.3.14 (server, bundler, test runner), TypeScript, three.js (mockups 06/07/13–17 only), Docker.
`AGENTS.md` is the single source of truth for agents here. `CLAUDE.md` and `GEMINI.md` only point here.
Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`.
@@ -10,7 +10,7 @@ Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TOD
Install: `bun install --frozen-lockfile`
Dev: `bun run dev` (`PORT` env, default 3000; `CONTENT_DIR=~/epic-the-musical-content` for real audio/lyrics)
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 16"`
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 17"`
Typecheck: `bun run typecheck`
Build: `bun run build` | Run the build: `bun run start`
Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 -v ~/epic-the-musical-content:/content:ro -e CONTENT_DIR=/content epic-timeline`
+4
View File
@@ -74,3 +74,7 @@ persistent side panel. No new dependency.
read-only into the container. **Why:** the audio, lyrics and art are Jorge Rivera-Herrans' copyrighted
work; committing them would push them to Gitea and bake 1.5 GB into the image. The user chose this
("Mount it, never commit"). Range requests are handled in `src/content/routes.ts` so audio can seek.
## 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.
+1 -1
View File
@@ -22,7 +22,7 @@ or `AGENTS.md` Commands.
| `bun install --frozen-lockfile` | worked (no changes) |
| `bun test` | worked — 34 pass, 0 fail, 3 files |
| `bun test tests/data.test.ts` | worked — 10 pass |
| `bun test -t "registry has 16"` | worked — 1 pass |
| `bun test -t "registry has 17"` | worked — 1 pass |
| `bun run typecheck` | worked (`tsc --noEmit`, exit 0) |
| `bun run build` | worked — `dist/server.js` + hashed page assets |
| `bun run dev` (`PORT=3113`) | worked — `/` and all mockup routes 200, no-slash form 302 |
+7
View File
@@ -334,6 +334,13 @@ and brief one agent from a copy of 16, using `/content/library.json`, `parseLrc`
`src/shared/lrc.ts` and `/content/homer/odyssey/<book>.json`. Its test server must run with
`CONTENT_DIR=/home/user/epic-the-musical-content`.
### 19. Resumed
> "Continue."
- Resumed from §18. 17 (real content) is started now, as agreed; the user's reaction to 16 is asked
in parallel.
## Open questions (in priority order)
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
@@ -0,0 +1,70 @@
# 17 Voyage with real content — brief
## Why
The user added the real content (`~/epic-the-musical-content`, see round notes §17) and agreed:
"Yes, in 17 after 16" — 17 uses **real FLAC playback, line-synced lyrics from the `.lrc` files, and
the actual Odyssey passage**. Spec `docs/specs/003-content-mount.md` added the server side; read it.
17 starts from a **copy of 16** (`src/mockups/16-voyage-readable/`). Keep everything from 16 (look 2,
readable Inter labels, frieze accents, the five beats, stylised 3D, panel, combined paths, fan works
placeholders). Change only the content side.
## What the server gives you (only when `CONTENT_DIR` is set)
- `GET /content/library.json` → `{ sagas: [{ name, year, cover, songs: [{ track, title, audio,
lyrics: { url, timed } | null }] }] }` (types in `src/content/library.ts`; import the types only).
- `GET <audio url>` → FLAC with HTTP range support (seeking works in `<audio>`).
- `GET <lyrics url>` → `.lrc` (timed) or `.txt` (plain). Parse `.lrc` with `parseLrc` from
`src/shared/lrc.ts` → `{ time, text }[]`.
- `GET /content/homer/odyssey/<book>.json` → `{ book, heading, summary, paragraphs[] }` (Butler's prose
translation; Roman names: Ulysses, Minerva, Jove, Neptune).
- Without the folder every `/content/*` is 404 → 17 must **fall back gracefully** to 16's behaviour
(placeholder lyrics, simulated player) with a small note "Content folder not mounted".
## Changes
1. **Match sample songs to the library** by normalised title (case/punctuation-insensitive). Log, don't
crash, on no match.
2. **Real player** in the Listen & lyrics tab and the header play button: a real `<audio>` with the
FLAC URL; play/pause, elapsed/total from the audio, the scrub bar seeks. Drop the "Preview player"
note when real audio is used. Saga cover art (`cover`) as a small square in the player.
3. **Line-synced lyrics**: render the LRC lines; the current line is highlighted and kept in view
(smooth scroll, not jumpy; don't fight the user if they scroll manually — resume after ~4 s).
Clicking a line seeks to it. Plain `.txt` lyrics: show them unsynced with a note. Part headings:
LRC has no parts, so drop part headings in the lyrics list; keep the sample's parts + motif chips in
a compact summary above the lyrics ("Parts: Verse 1 · Polyphemus …") — and say in the report how
parts/motifs could be time-aligned later.
Real lyrics are copyrighted: they are shown only from the mounted folder at runtime — never copy any
lyric text into source files, tests, reports or screenshots' file names. Screenshots showing them are
fine (screenshots/ is gitignored).
4. **The Odyssey passage**: the About tab's Odyssey reference opens the real text. The sample data has
book numbers; fetch that book and show heading, summary, and the paragraphs, starting at the first
paragraph that mentions a keyword for the song (e.g. Polyphemus → "Polyphemus"/"Cyclops";
fall back to the book start), in a readable reading view (a drawer over the panel or a "Read" sub-view
with back). Add a one-line note: "Samuel Butler's translation (public domain) uses Roman names:
Ulysses = Odysseus, Minerva = Athena …". Replace "lines 105–460" displays with "Book IX" since the
prose has no lines.
5. **Voyage play**: when "Play voyage" runs and a beat's song has audio, it may play a short excerpt
— optional, your call; say what you chose. Never autoplay on page load.
## Rules
- Edit ONLY `src/mockups/17-voyage-real/` (stubs exist; index.html links credits; main.ts calls
`mountCredits()`; replace the stub style.css with unique content). You may import from `src/shared/`
and types from `src/content/` but don't change them. three.js allowed. No new dependencies.
- Credits footer fully visible in every state.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-17`,
serve with `cd <outdir> && PORT=4117 CONTENT_DIR=/home/user/epic-the-musical-content bun server.js`.
Kill only your own server.
- Screenshots 1440×900 with `--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader
--virtual-time-budget=6000 --autoplay-policy=no-user-gesture-required`:
`screenshots/17-voyage-real-listen.png` (beat 3, playing, mid-song e.g. `?t_play=60`, current line
highlighted), `-odyssey.png` (the passage view for Polyphemus), `-txt.png` (if a sample song maps to a
`.txt` lyric; otherwise skip), `-nocontent.png` (served WITHOUT `CONTENT_DIR`, fallback), `-beat1.png`.
Also verify with Playwright or a script that the audio element's `currentTime` advances and seeking
works against the real server. Look at every screenshot; fix what's off.
- `bun test` and `bun run typecheck` must pass. Don't commit. Don't spawn subagents.
- If you run low on budget, stop and write a pause report (done / checklist / next step) instead.
- Report to `docs/design/002-sea-chart-spikes/17-voyage-real-report.md` (no lyric text in it): what you
built, what works, what doesn't, screenshots, concerns.
+1
View File
@@ -29,6 +29,7 @@
<li class="card"><a href="/mockups/14-voyage-panel/"><span class="card__num">14</span><h2>Voyage with side panel</h2><p>The curtain-call voyage with stylised models and a side panel that stays open: about, paths, lyrics, fan works.</p><p class="card__try">Try this: Play the voyage and watch the panel follow; open Lyrics and Fan works; switch the look (2/3).</p></a></li>
<li class="card"><a href="/mockups/15-voyage-look2/"><span class="card__num">15</span><h2>Voyage, look 2</h2><p>14 in look 2, with the frieze, panel and world tied together, and the motif paths out in the open.</p><p class="card__try">Try this: Step through the voyage and switch where the paths live (A strip, B in the sea, C timeline).</p></a></li>
<li class="card"><a href="/mockups/16-voyage-readable/"><span class="card__num">16</span><h2>Voyage, readable</h2><p>15 with readable type, listening folded into the lyrics, and all three motif paths working together.</p><p class="card__try">Try this: Press play in the panel header, follow the lyrics, collapse the timeline, and turn on threads on the sea.</p></a></li>
<li class="card"><a href="/mockups/17-voyage-real/"><span class="card__num">17</span><h2>Voyage with real content</h2><p>16 with the real songs, line-by-line synced lyrics and the actual Odyssey passage (needs the content folder).</p><p class="card__try">Try this: Press play on Polyphemus, watch the lyrics follow the song, then open the Odyssey passage.</p></a></li>
</ul>
</main>
<footer data-fan-credits></footer>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Voyage with real content — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
+1
View File
@@ -0,0 +1 @@
body { margin: 0; --stub-17: 1; }
+3
View File
@@ -49,4 +49,7 @@ export const mockups: MockupInfo[] = [
{ slug: "16-voyage-readable", number: 16, name: "Voyage, readable",
pitch: "15 with readable type, listening folded into the lyrics, and all three motif paths working together.",
tryThis: "Press play in the panel header, follow the lyrics, collapse the timeline, and turn on threads on the sea." },
{ slug: "17-voyage-real", number: 17, name: "Voyage with real content",
pitch: "16 with the real songs, line-by-line synced lyrics and the actual Odyssey passage (needs the content folder).",
tryThis: "Press play on Polyphemus, watch the lyrics follow the song, then open the Odyssey passage." },
];
+3
View File
@@ -17,6 +17,7 @@ import m12 from "./mockups/13-curtain-call/index.html";
import m13 from "./mockups/14-voyage-panel/index.html";
import m14 from "./mockups/15-voyage-look2/index.html";
import m15 from "./mockups/16-voyage-readable/index.html";
import m16 from "./mockups/17-voyage-real/index.html";
export interface ServerOptions {
/** Folder with the real content (audio, lyrics, Homer); defaults to CONTENT_DIR. See spec 003. */
@@ -61,6 +62,8 @@ export function startServer(port: number, opts: ServerOptions = {}): Server<unde
"/mockups/15-voyage-look2": Response.redirect("/mockups/15-voyage-look2/", 302),
"/mockups/16-voyage-readable/": m15,
"/mockups/16-voyage-readable": Response.redirect("/mockups/16-voyage-readable/", 302),
"/mockups/17-voyage-real/": m16,
"/mockups/17-voyage-real": Response.redirect("/mockups/17-voyage-real/", 302),
},
development: process.env.NODE_ENV !== "production",
fetch(req) {
+1 -1
View File
@@ -8,7 +8,7 @@ let base: string;
beforeAll(() => { server = startServer(0); base = `http://localhost:${server.port}`; });
afterAll(() => server.stop(true));
test("registry has 16 mockups", () => expect(mockups).toHaveLength(16));
test("registry has 17 mockups", () => expect(mockups).toHaveLength(17));
test("index links every mockup", async () => {
const res = await fetch(base + "/");