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 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 15:11:43 +01:00
co-authored by Claude Opus 5.5
parent ac017787b5
commit d355b3a41f
5 changed files with 185 additions and 18 deletions
+1 -1
View File
@@ -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
+28 -8
View File
@@ -7,12 +7,14 @@ export interface ControllerWorld {
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;
}
/** The parts of the frieze curtain the controller drives (src/app/frieze/curtain.ts). */
export interface ControllerCurtain {
drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>;
lift(): Promise<void>;
fade(): Promise<void>;
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<void>;
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<void> = Promise.resolve();
let timer: ReturnType<typeof setTimeout> | 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); },
};
}
+105 -8
View File
@@ -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<LandingId, string> = { 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 = `<p class="cap__eyebrow"></p><h1 class="cap__title"></h1>`;
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 = `
<button type="button" class="vc vc--play" data-act="play" aria-pressed="false">Play voyage</button>
<button type="button" class="vc" data-act="prev" aria-label="Previous song">←</button>
<button type="button" class="vc" data-act="next" aria-label="Next song">→</button>`;
const playBtn = controls.querySelector<HTMLButtonElement>("[data-act=play]")!;
function step(delta: 1 | -1) {
controller.stop();
const track = songById(store.get().songId)!.track + delta;
const next = [...document.querySelectorAll<HTMLElement>("#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<HTMLElement>("[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(); });
+19
View File
@@ -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); }
+32 -1
View File
@@ -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");
});