From d355b3a41fd63216a2740d4bbecf67cde5735f1a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:11:43 +0100 Subject: [PATCH] feat(app): wire the voyage together Controller drives world and curtain; caption with Back to sea; play, previous and next in the timeline bar; keyboard; no-WebGL frieze stage. Co-Authored-By: Claude Opus 5.5 --- docs/plans/002-voyage-app.md | 2 +- src/app/controller.ts | 36 ++++++++--- src/app/main.ts | 113 ++++++++++++++++++++++++++++++++--- src/app/style.css | 19 ++++++ tests/voyage.test.ts | 33 +++++++++- 5 files changed, 185 insertions(+), 18 deletions(-) diff --git a/docs/plans/002-voyage-app.md b/docs/plans/002-voyage-app.md index 76f0cbd..fa9a4ad 100644 --- a/docs/plans/002-voyage-app.md +++ b/docs/plans/002-voyage-app.md @@ -185,7 +185,7 @@ export function transitionFor(fromSongId: string, toSongId: string): Step; ``` 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`. +- [x] 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 diff --git a/src/app/controller.ts b/src/app/controller.ts index 1112600..bf71789 100644 --- a/src/app/controller.ts +++ b/src/app/controller.ts @@ -7,12 +7,14 @@ export interface ControllerWorld { sailTo(songId: string, opts?: { instant?: boolean }): Promise; setLanding(id: LandingId | null): Promise; setRibbons(motifIds: string[] | null, highlight: string | null): void; + horizonFromFrieze(groundYpx: number): void; } /** The parts of the frieze curtain the controller drives (src/app/frieze/curtain.ts). */ export interface ControllerCurtain { drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise; lift(): Promise; fade(): Promise; + groundY(): number; } export interface Controller { @@ -23,6 +25,11 @@ export interface Controller { stop(): void; playing(): boolean; onPlayingChange(fn: (playing: boolean) => void): void; + /** The landing the stage is in, if any. */ + landing(): LandingId | null; + /** "Back to sea": leave the landing, keep the song. */ + leaveLanding(): Promise; + onLandingChange(fn: (landing: LandingId | null) => void): void; } const PLAY_HOLD_MS = 3400; @@ -30,7 +37,8 @@ const PLAY_HOLD_MS = 3400; /** * Moves the stage when the song changes. Steps run one at a time; a newer request makes the running step * stop at its next await, and the last request wins. A curtain left down by a cancelled step is lifted by - * the next one, so rapid navigation never leaves the stage covered. + * the next one, so rapid navigation never leaves the stage covered. Order under a curtain: cover → sail → + * landing → horizon on the frieze's ground line → lift (a landing drop lifts through the doorway). */ export function createController(opts: { store: Store; @@ -47,6 +55,8 @@ export function createController(opts: { let queue: Promise = Promise.resolve(); let timer: ReturnType | null = null; const playListeners: ((p: boolean) => void)[] = []; + const landingListeners: ((l: LandingId | null) => void)[] = []; + const setLanded = (l: LandingId | null) => { landed = l; landingListeners.forEach((fn) => fn(l)); }; const ribbons = () => { const s = store.get(); @@ -68,18 +78,20 @@ export function createController(opts: { if (world) { if (landed && landed !== landing) { await world.setLanding(null); - landed = null; + setLanded(null); } await world.sailTo(to, { instant: kind !== "sail" }); } if (my !== gen) return; - if (curtainDown) { - await curtain.lift(); - curtainDown = false; - } if (world && landing !== landed) { await world.setLanding(landing); - landed = landing; + setLanded(landing); + } + if (my !== gen) return; + if (curtainDown) { + world?.horizonFromFrieze(curtain.groundY()); + await curtain.lift(); + curtainDown = false; } settled = to; ribbons(); @@ -118,7 +130,7 @@ export function createController(opts: { const first = songById(settled)!; running++; queue = world.sailTo(settled, { instant: true }) - .then(async () => { if (first.landing) { await world.setLanding(first.landing); landed = first.landing; } ribbons(); }) + .then(async () => { if (first.landing) { await world.setLanding(first.landing); setLanded(first.landing); } ribbons(); }) .catch((e) => console.error(e)) .finally(() => { running--; }); } @@ -130,5 +142,13 @@ export function createController(opts: { stop() { setPlaying(false); }, playing: () => playingNow, onPlayingChange(fn) { playListeners.push(fn); }, + landing: () => landed, + leaveLanding() { + if (!world || !landed) return Promise.resolve(); + const done = queue.then(async () => { await world.setLanding(null); setLanded(null); }); + queue = done.catch((e) => console.error(e)); + return done; + }, + onLandingChange(fn) { landingListeners.push(fn); }, }; } diff --git a/src/app/main.ts b/src/app/main.ts index 7175d67..83cdb6c 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -1,18 +1,115 @@ import { mountCredits } from "../shared/credits"; -import { sagaOf, songById } from "../data/album"; +import { sagaOf, songById, type LandingId, type PlaceId } from "../data/album"; import { createStore, parseState } from "./state"; +import { mountPanel } from "./panel/panel"; +import { mountTimeline } from "./timeline/timeline"; +import { createWorld } from "./world/world"; +import { createCurtain } from "./frieze/curtain"; +import { createController, type ControllerCurtain } from "./controller"; mountCredits(); +const $ = (id: string) => document.getElementById(id)!; const store = createStore(parseState(location.search)); +const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; +const narrow = matchMedia("(max-width: 900px)"); -// Placeholder caption until the voyage controller (plan task 7) takes over the stage. -const caption = document.getElementById("caption")!; -const render = () => { +const world = createWorld($("world") as HTMLCanvasElement); +const curtain = createCurtain($("frieze-layer")); +document.body.dataset.webgl = world ? "yes" : "no"; + +/** + * Without WebGL the frieze is the stage: it is dropped for the current saga and never lifted, so the + * panel and the timeline still browse the whole album (spec 002, goal 9). + */ +const stageCurtain: ControllerCurtain = world + ? curtain + : { + drop: (sagaId: PlaceId, opts?: { landing?: LandingId }) => curtain.drop(sagaId, opts), + fade: () => curtain.drop(sagaOf(songById(store.get().songId)!).id), + lift: () => Promise.resolve(), + groundY: () => curtain.groundY(), + }; +if (!world) { + void curtain.drop(sagaOf(songById(store.get().songId)!).id); + const note = document.createElement("p"); + note.className = "stage__note"; + note.textContent = "3D needs WebGL — browsing with the frieze."; + $("stage").append(note); +} + +const controller = createController({ store, world, curtain: stageCurtain, reducedMotion }); + +mountPanel($("panel"), store, { timelineHidden: () => narrow.matches }); +const timeline = mountTimeline($("timeline"), store); + +/* ---------- caption card: saga · story time, song title, Back to sea in a landing ---------- */ + +const LANDING_NAME: Record = { cave: "The Cyclops' cave", arena: "God Games", hall: "The hall of Ithaca" }; +const caption = $("caption"); +function renderCaption() { const song = songById(store.get().songId)!; + const landing = controller.landing(); caption.innerHTML = `

`; - caption.querySelector(".cap__eyebrow")!.textContent = `${sagaOf(song).name} · ${song.storyTime.label}`; + caption.querySelector(".cap__eyebrow")!.textContent = landing + ? `Ashore · ${LANDING_NAME[landing]}` + : `${sagaOf(song).name} · ${song.storyTime.label}`; caption.querySelector(".cap__title")!.textContent = song.title; -}; -store.subscribe(render); -render(); + if (landing) { + const back = document.createElement("button"); + back.type = "button"; + back.className = "cap__back"; + back.textContent = "Back to sea"; + back.addEventListener("click", () => void controller.leaveLanding()); + caption.append(back); + } +} +store.subscribe((s, prev) => { if (s.songId !== prev.songId) renderCaption(); }); +controller.onLandingChange(renderCaption); +renderCaption(); + +/* ---------- play / previous / next, in the timeline's controls slot ---------- */ + +const controls = $("timeline").querySelector(".timeline__controls")!; +controls.innerHTML = ` + + + `; +const playBtn = controls.querySelector("[data-act=play]")!; +function step(delta: 1 | -1) { + controller.stop(); + const track = songById(store.get().songId)!.track + delta; + const next = [...document.querySelectorAll("#timeline [data-song]")] + .map((el) => el.dataset.song!) + .find((id) => songById(id)?.track === track); + if (next) store.set({ songId: next }); +} +controls.addEventListener("click", (e) => { + const act = (e.target as HTMLElement).closest("[data-act]")?.dataset.act; + if (act === "play") (controller.playing() ? controller.stop() : controller.play()); + else if (act === "prev") step(-1); + else if (act === "next") step(1); +}); +controller.onPlayingChange((p) => { + playBtn.textContent = p ? "Pause voyage" : "Play voyage"; + playBtn.setAttribute("aria-pressed", String(p)); +}); + +/* ---------- keyboard: ←/→ songs, Esc closes the reading view or leaves a landing ---------- */ + +addEventListener("keydown", (e: KeyboardEvent) => { + if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return; + const t = e.target as HTMLElement; + if (t.closest("input, textarea, select, [contenteditable], [role=slider], #timeline, .lyrics")) return; + if (e.key === "ArrowRight") { step(1); e.preventDefault(); } + else if (e.key === "ArrowLeft") { step(-1); e.preventDefault(); } + else if (e.key === "Escape") { + if (store.get().read) store.set({ read: false }); + else if (controller.landing()) void controller.leaveLanding(); + } +}); +for (const ev of ["pointerdown", "wheel"] as const) $("stage").addEventListener(ev, () => controller.stop(), { passive: true }); + +/* ---------- layout ---------- */ + +addEventListener("resize", () => { world?.resize(); timeline.refresh(); }); diff --git a/src/app/style.css b/src/app/style.css index 406fe22..9da1dcc 100644 --- a/src/app/style.css +++ b/src/app/style.css @@ -106,3 +106,22 @@ button { font: inherit; color: inherit; } .panel { max-height: 50dvh; border-left: 0; border-top: 2px solid var(--glaze); } .shell[data-panel="closed"] .panel { max-height: 56px; } } + +/* caption: Back to sea; the no-WebGL note */ +.cap__back { + margin-top: 10px; padding: 6px 14px; border: 2px solid var(--glaze); border-radius: 999px; + background: var(--glaze); color: var(--added-white); font-size: var(--fs-small); font-weight: 600; cursor: pointer; +} +.cap__back:hover { background: var(--clay-deep); } +.stage__note { + position: absolute; z-index: 4; top: 22px; right: 24px; margin: 0; padding: 8px 12px; + background: var(--vellum-hi); border: 2px solid var(--glaze); font-size: var(--fs-small); +} +/* voyage controls in the timeline's bar */ +.vc { + min-width: 36px; height: 34px; padding: 0 12px; border: 1.5px solid var(--glaze); border-radius: 999px; + background: transparent; color: var(--iron); font-size: var(--fs-small); font-weight: 600; cursor: pointer; +} +.vc:hover { background: var(--vellum); } +.vc--play { background: var(--glaze); color: var(--added-white); } +.vc--play:hover { background: var(--clay-deep); } diff --git a/tests/voyage.test.ts b/tests/voyage.test.ts index 115e563..3289708 100644 --- a/tests/voyage.test.ts +++ b/tests/voyage.test.ts @@ -29,11 +29,13 @@ function fakes() { sailTo: (id) => later(`sail:${id}`), setLanding: (id) => later(`land:${id}`), setRibbons: () => {}, + horizonFromFrieze: (y) => { log.push(`horizon:${y}`); }, }; const curtain: ControllerCurtain = { drop: (saga) => later(`drop:${saga}`), lift: () => later("lift"), fade: () => later("fade"), + groundY: () => 500, }; const flush = async () => { for (let i = 0; i < 50; i++) { @@ -57,7 +59,9 @@ test("rapid navigation ends at the last requested song with the curtain lifted", const drops = f.log.filter((l) => l.startsWith("drop:") || l === "fade").length; const lifts = f.log.filter((l) => l === "lift").length; expect(lifts).toBeGreaterThanOrEqual(drops === 0 ? 0 : 1); - expect(f.log.at(-1)).toBe("land:arena"); + // covered → sail → landing → lift: the stage is uncovered last, already inside the arena + expect(f.log.lastIndexOf("land:arena")).toBeLessThan(f.log.lastIndexOf("lift")); + expect(f.log.at(-1)).toBe("lift"); expect(ctl.busy()).toBe(false); }); @@ -71,6 +75,19 @@ test("without a world the controller still follows the songs (frieze-only browsi expect(f.log).toContain("drop:cyclops"); }); +test("the horizon is matched to the frieze's ground line before the curtain lifts", async () => { + const f = fakes(); + const store = createStore(DEFAULT_STATE, { history: false }); + createController({ store, world: f.world, curtain: f.curtain, reducedMotion: false }); + await f.flush(); + store.set({ songId: "just-a-man" }); // same saga: no curtain + await f.flush(); + expect(f.log.filter((l) => l.startsWith("horizon:"))).toHaveLength(0); + store.set({ songId: "polyphemus" }); // next saga: curtain + await f.flush(); + expect(f.log.indexOf("horizon:500")).toBeLessThan(f.log.lastIndexOf("lift")); +}); + test("reduced motion turns every scene change into a fade", async () => { const f = fakes(); const store = createStore(DEFAULT_STATE, { history: false }); @@ -80,3 +97,17 @@ test("reduced motion turns every scene change into a fade", async () => { expect(f.log).toContain("fade"); expect(f.log.some((l) => l.startsWith("drop:"))).toBe(false); }); + +test("Back to sea leaves the landing without changing the song", async () => { + const f = fakes(); + const store = createStore({ ...DEFAULT_STATE, songId: "survive" }, { history: false }); + const ctl = createController({ store, world: f.world, curtain: f.curtain, reducedMotion: false }); + await f.flush(); + expect(ctl.landing()).toBe("cave"); + const left = ctl.leaveLanding(); + await f.flush(); + await left; + expect(f.log.at(-1)).toBe("land:null"); + expect(ctl.landing()).toBeNull(); + expect(store.get().songId).toBe("survive"); +});