diff --git a/src/mockups/02-vertical-story/index.html b/src/mockups/02-vertical-story/index.html
index ff226a2..44dbb56 100644
--- a/src/mockups/02-vertical-story/index.html
+++ b/src/mockups/02-vertical-story/index.html
@@ -7,7 +7,8 @@
-
+
+
diff --git a/src/mockups/02-vertical-story/main.ts b/src/mockups/02-vertical-story/main.ts
index e4cecdc..96947eb 100644
--- a/src/mockups/02-vertical-story/main.ts
+++ b/src/mockups/02-vertical-story/main.ts
@@ -1,5 +1,362 @@
+import { motifs, sagas, songs } from "../../data/sample";
+import { gapYears, motifsInSong, sagaOf, songsWithMotif } from "../../data/query";
+import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
+import { createAudioSlot } from "../../shared/audio-slot";
+import { createVideoCards } from "../../shared/video-cards";
mountCredits();
+
const app = document.getElementById("app")!;
-app.innerHTML = `Vertical story
stub
`;
+const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+const NUMBER_WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
+const yearsPhrase = (n: number) =>
+ n === 1 ? "A year passes" : `${NUMBER_WORDS[n] ?? n} years pass`;
+const motifById = (id: string) => motifs.find((m) => m.id === id);
+
+function el(
+ tag: K,
+ cls?: string,
+ text?: string,
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (cls) node.className = cls;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+/* ---------- Opening ---------- */
+
+const opening = el("header", "opening");
+opening.innerHTML = `
+ EPIC: The Musical, told as a voyage
+ From Troy,
the long way home
+ Scroll down to sail. Each song surfaces as the ship reaches it, and the
+ distance between songs follows the years that pass in the story.
+
`;
+
+/* ---------- Motif rail ---------- */
+
+const rail = el("nav", "rail");
+rail.setAttribute("aria-label", "Follow a motif");
+const railLabel = el("p", "rail__label", "Follow a theme or character");
+const railChips = el("div", "rail__chips");
+const railNote = el("p", "rail__note");
+railNote.setAttribute("aria-live", "polite");
+// mini-map of the whole voyage: one tick per song, lit where the selected motif sounds
+const railMap = el("div", "rail__map");
+railMap.setAttribute("aria-label", "Where it sounds across the voyage");
+rail.append(railLabel, railChips, railMap, railNote);
+
+function motifChip(m: Motif, variant: "rail" | "part"): HTMLButtonElement {
+ const b = el("button", `chip chip--${variant}`);
+ b.type = "button";
+ b.dataset.motif = m.id;
+ b.dataset.kind = m.kind;
+ b.style.setProperty("--mc", m.color);
+ b.title = m.description;
+ b.append(el("span", "chip__dot"), el("span", "chip__name", m.name));
+ b.addEventListener("click", (e) => {
+ e.stopPropagation();
+ selectMotif(selectedMotif === m.id ? null : m.id);
+ });
+ return b;
+}
+for (const kind of ["motif", "character"] as const) {
+ const group = el("div", "rail__group");
+ group.setAttribute("aria-label", kind === "motif" ? "Themes" : "Characters");
+ for (const m of motifs.filter((x) => x.kind === kind)) group.append(motifChip(m, "rail"));
+ railChips.append(group);
+}
+
+/* ---------- The course (vertical line) ---------- */
+
+const voyage = el("div", "voyage");
+const course = el("section", "course");
+course.setAttribute("aria-label", "Songs in story order");
+const line = el("div", "course__line");
+const wake = el("div", "course__wake");
+const ship = el("div", "course__ship");
+line.append(wake, ship);
+const lane = el("div", "course__lane");
+lane.append(line);
+course.append(opening, lane);
+
+interface Stop { song: Song; root: HTMLElement; node: HTMLButtonElement; pips: HTMLElement[]; tick: HTMLButtonElement }
+const stops: Stop[] = [];
+
+let prev: Song | null = null;
+for (const song of songs) {
+ const saga = sagaOf(song);
+ const years = prev ? gapYears(prev, song) : 0;
+
+ if (prev) {
+ const gap = el("div", "gap");
+ gap.style.setProperty("--years", String(years));
+ if (years > 0) {
+ gap.classList.add("gap--years");
+ const label = el("p", "gap__label");
+ label.append(el("span", "gap__phrase", yearsPhrase(years)), el("span", "gap__scale", `${years} yr at sea`));
+ gap.append(label);
+ }
+ lane.append(gap);
+ }
+
+ if (!prev || prev.sagaId !== song.sagaId) {
+ const marker = el("div", "saga");
+ marker.dataset.saga = saga.id;
+ marker.append(el("span", "saga__order", `Saga ${saga.order} of ${sagas.length} in this sample`), el("h2", "saga__name", saga.name));
+ lane.append(marker);
+ }
+
+ const root = el("article", "stop");
+ root.dataset.id = song.id;
+ root.dataset.saga = song.sagaId;
+ const showYear = !prev || prev.storyTime.yearsAfterTroy !== song.storyTime.yearsAfterTroy;
+ const yearMark = el("p", "stop__year", showYear ? `Year ${song.storyTime.yearsAfterTroy}` : "");
+ yearMark.setAttribute("aria-hidden", "true");
+
+ const node = el("button", "stop__node");
+ node.type = "button";
+ node.setAttribute("aria-label", `Sail to ${song.title}`);
+
+ const body = el("div", "stop__body");
+ const trackNo = el("p", "stop__track", `Track ${song.track}`);
+ const title = el("h3", "stop__title", song.title);
+ const when = el("p", "stop__when", song.storyTime.label);
+ const pipRow = el("div", "stop__pips");
+ pipRow.setAttribute("aria-hidden", "true");
+ const pips = song.parts.map((p) => {
+ const pip = el("span", "pip");
+ pip.dataset.motifs = p.motifIds.join(" ");
+ pip.title = p.name;
+ pipRow.append(pip);
+ return pip;
+ });
+ body.append(trackNo, title, when, pipRow);
+ root.append(yearMark, node, body);
+ lane.append(root);
+
+ const go = () => sailTo(song.id, true);
+ node.addEventListener("click", go);
+ body.addEventListener("click", go);
+ const tick = el("button", "map__tick");
+ tick.type = "button";
+ tick.title = song.title;
+ tick.setAttribute("aria-label", `Sail to ${song.title}`);
+ tick.style.setProperty("--years", String(years));
+ if (prev && prev.sagaId !== song.sagaId) tick.classList.add("map__tick--saga");
+ tick.addEventListener("click", go);
+ railMap.append(tick);
+ stops.push({ song, root, node, pips, tick });
+ prev = song;
+}
+
+const coda = el("div", "coda");
+coda.innerHTML = `The sample voyage ends here.
Ithaca is still over the horizon.
`;
+lane.append(coda);
+
+/* ---------- Sticky detail panel ---------- */
+
+const panel = el("aside", "panel");
+panel.setAttribute("aria-live", "polite");
+panel.setAttribute("aria-label", "Song detail");
+const panelStage = el("div", "panel__stage");
+panel.append(rail, panelStage);
+
+voyage.append(course, panel);
+app.append(voyage);
+
+function renderDetail(song: Song): HTMLElement {
+ const saga = sagaOf(song);
+ const d = el("div", "detail");
+
+ const head = el("header", "detail__head");
+ head.append(
+ el("p", "detail__where", `${saga.name}, track ${song.track}`),
+ el("h2", "detail__title", song.title),
+ );
+
+ const facts = el("dl", "detail__facts");
+ const time = el("div", "fact-row");
+ time.append(el("dt", "", "Story time"));
+ const timeDd = el("dd");
+ timeDd.append(
+ el("span", "detail__year", `Year ${song.storyTime.yearsAfterTroy} after Troy`),
+ el("span", "detail__label", song.storyTime.label),
+ );
+ time.append(timeDd);
+
+ const ody = el("div", "fact-row");
+ ody.append(el("dt", "", "In the Odyssey"));
+ const odyDd = el("dd");
+ if (song.odyssey) {
+ const a = el("a", "detail__ref", `Book ${song.odyssey.book}, lines ${song.odyssey.lines.replace("-", "–")}`);
+ a.href = song.odyssey.url;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ odyDd.append(a, el("span", "detail__label", "Samuel Butler translation, Project Gutenberg"));
+ } else {
+ odyDd.append(el("span", "detail__label", "No counterpart in Homer's text"));
+ }
+ ody.append(odyDd);
+ facts.append(time, ody);
+
+ const partsH = el("h3", "detail__h", "Parts");
+ const parts = el("ol", "parts");
+ song.parts.forEach((p) => {
+ const li = el("li", "part");
+ li.dataset.motifs = p.motifIds.join(" ");
+ const top = el("div", "part__top");
+ top.append(el("span", "part__name", p.name), el("span", "part__singers", `Sung by ${p.singers.join(" and ")}`));
+ const lyr = el("div", "part__lines");
+ for (const l of p.lines) lyr.append(el("p", "", l));
+ const chips = el("div", "part__chips");
+ for (const id of p.motifIds) {
+ const m = motifById(id);
+ if (m) chips.append(motifChip(m, "part"));
+ }
+ li.append(top, lyr, chips);
+ parts.append(li);
+ });
+
+ const factH = el("h3", "detail__h", "Footnote");
+ const fact = el("p", "detail__fact", song.facts[0] ?? "");
+
+ const listenH = el("h3", "detail__h", "Listen");
+ const watchH = el("h3", "detail__h", "Watch on YouTube");
+
+ d.append(head, facts, partsH, parts, factH, fact, listenH, createAudioSlot(song), watchH, createVideoCards(song));
+ return d;
+}
+
+/* ---------- Motif selection ---------- */
+
+let selectedMotif: string | null = null;
+
+function selectMotif(id: string | null) {
+ selectedMotif = id;
+ const m = id ? motifById(id) : undefined;
+ document.body.toggleAttribute("data-motif", !!m);
+ if (m) document.body.style.setProperty("--sel", m.color);
+
+ for (const chip of document.querySelectorAll(".chip")) {
+ const on = chip.dataset.motif === id;
+ chip.classList.toggle("is-on", on);
+ chip.setAttribute("aria-pressed", String(on));
+ }
+ const hits = m ? new Set(songsWithMotif(m.id).map((s) => s.id)) : new Set();
+ let partCount = 0;
+ for (const s of stops) {
+ s.root.classList.toggle("is-hit", hits.has(s.song.id));
+ s.tick.classList.toggle("is-hit", hits.has(s.song.id));
+ for (const pip of s.pips) {
+ const on = !!id && pip.dataset.motifs!.split(" ").includes(id);
+ pip.classList.toggle("is-hit", on);
+ if (on) partCount++;
+ }
+ }
+ applyPartHighlight();
+ railNote.textContent = m
+ ? `${m.name} sounds in ${hits.size} of ${songs.length} songs, ${partCount} parts. ${m.description}`
+ : "Pick one to light every song and part where it sounds.";
+}
+
+function applyPartHighlight() {
+ for (const li of panelStage.querySelectorAll(".part")) {
+ li.classList.toggle("is-hit", !!selectedMotif && li.dataset.motifs!.split(" ").includes(selectedMotif));
+ }
+}
+
+/* ---------- Scroll engine ---------- */
+
+let activeId: string | null = null;
+
+function setActive(stop: Stop) {
+ if (stop.song.id === activeId) return;
+ activeId = stop.song.id;
+ document.body.dataset.saga = stop.song.sagaId;
+ const next = renderDetail(stop.song);
+ const old = panelStage.firstElementChild as HTMLElement | null;
+ if (old && !reduceMotion) {
+ old.classList.add("is-leaving");
+ old.addEventListener("animationend", () => old.remove(), { once: true });
+ setTimeout(() => old.remove(), 600);
+ } else {
+ old?.remove();
+ }
+ panelStage.append(next);
+ applyPartHighlight();
+ // keep motif chips in the new detail in sync
+ if (selectedMotif) for (const c of next.querySelectorAll(".chip")) {
+ const on = c.dataset.motif === selectedMotif;
+ c.classList.toggle("is-on", on);
+ c.setAttribute("aria-pressed", String(on));
+ }
+ for (const s of stops) {
+ s.node.setAttribute("aria-current", String(s === stop));
+ s.tick.classList.toggle("is-here", s === stop);
+ }
+}
+
+const focusRatio = () => (window.innerWidth <= 860 ? 0.3 : 0.5);
+
+function frame() {
+ ticking = false;
+ const vh = window.innerHeight;
+ const focusY = vh * focusRatio();
+ const lineRect = line.getBoundingClientRect();
+ const fill = Math.max(0, Math.min(lineRect.height, focusY - lineRect.top));
+ wake.style.height = `${fill}px`;
+ ship.style.transform = `translateY(${fill}px)`;
+
+ // the active song is the last one the ship has reached (first song before the voyage starts)
+ let best: Stop = stops[0]!;
+ for (const s of stops) {
+ const r = s.node.getBoundingClientRect();
+ const c = r.top + r.height / 2;
+ const dist = c - focusY; // > 0: still ahead (below)
+ const ahead = Math.max(0, Math.min(1, dist / (vh * 0.42)));
+ // ahead songs are veiled until the ship nears them; passed songs keep a soft afterglow
+ const passed = dist < 0 ? Math.min(1, -dist / (vh * 0.5)) : 0;
+ s.root.style.setProperty("--veil", ahead.toFixed(3));
+ s.root.style.setProperty("--passed", passed.toFixed(3));
+ if (dist <= vh * 0.06) best = s;
+ }
+ for (const s of stops) s.root.classList.toggle("is-active", s === best);
+ setActive(best);
+}
+
+let ticking = false;
+function onScroll() {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(frame);
+ }
+}
+window.addEventListener("scroll", onScroll, { passive: true });
+window.addEventListener("resize", onScroll);
+
+function sailTo(id: string, smooth: boolean) {
+ const s = stops.find((x) => x.song.id === id);
+ if (!s) return;
+ const r = s.node.getBoundingClientRect();
+ const y = window.scrollY + r.top + r.height / 2 - window.innerHeight * focusRatio();
+ window.scrollTo({ top: y, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
+ if (!smooth) frame();
+}
+
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && selectedMotif) selectMotif(null);
+});
+
+/* ---------- Initial state (query params used for screenshots) ---------- */
+
+selectMotif(null);
+frame();
+const params = new URLSearchParams(location.search);
+const startMotif = params.get("motif");
+if (startMotif && motifById(startMotif)) selectMotif(startMotif);
+const startSong = params.get("song");
+if (startSong) requestAnimationFrame(() => sailTo(startSong, false));
diff --git a/src/mockups/02-vertical-story/style.css b/src/mockups/02-vertical-story/style.css
index 91bc818..276b3b5 100644
--- a/src/mockups/02-vertical-story/style.css
+++ b/src/mockups/02-vertical-story/style.css
@@ -1,2 +1,470 @@
-main { padding: 2rem; font-family: system-ui, sans-serif; }
-h1::before { content: "02 · "; }
+/* 02 · Vertical story — a night voyage down a single bronze line, over the wine-dark sea. */
+@import "@fontsource/cormorant-garamond/latin-300.css";
+@import "@fontsource/cormorant-garamond/latin-300-italic.css";
+@import "@fontsource/cormorant-garamond/latin-500.css";
+@import "@fontsource/inter/latin-400.css";
+@import "@fontsource/inter/latin-500.css";
+@import "@fontsource/inter/latin-600.css";
+
+:root {
+ color-scheme: dark;
+ --troy: #2b1611; /* ember dusk over the burning city */
+ --cyclops: #172327; /* basalt and sea-cave */
+ --ocean: #200e27; /* the wine-dark sea */
+ --bronze: #cf9a5e;
+ --bronze-dim: #7d5d3c;
+ --foam: #f1e6d4;
+ --mist: #ad9e8c;
+ --serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
+ --sans: "Inter", system-ui, sans-serif;
+ --lx: clamp(5.5rem, 9vw, 8.5rem); /* x of the voyage line inside the course */
+ --sel: var(--bronze);
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: auto; }
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ --bg: var(--troy);
+ background-color: var(--bg);
+ color: var(--foam);
+ font-family: var(--sans);
+ font-size: 15px;
+ line-height: 1.55;
+ transition: --bg 1.6s ease;
+ -webkit-font-smoothing: antialiased;
+}
+body[data-saga="cyclops"] { --bg: var(--cyclops); }
+body[data-saga="ocean"] { --bg: var(--ocean); }
+
+@property --bg { syntax: ""; inherits: true; initial-value: #2b1611; }
+
+/* light + grain layer that drifts with the saga */
+.sea {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background:
+ radial-gradient(60rem 40rem at 18% -10%, rgb(255 170 90 / 0.16), transparent 70%),
+ radial-gradient(50rem 50rem at 30% 120%, rgb(20 0 30 / 0.55), transparent 70%);
+ transition: opacity 1.6s ease;
+}
+body[data-saga="cyclops"] .sea {
+ background:
+ radial-gradient(50rem 30rem at 20% -10%, rgb(120 190 190 / 0.10), transparent 70%),
+ radial-gradient(60rem 50rem at 30% 120%, rgb(0 0 0 / 0.5), transparent 70%);
+}
+body[data-saga="ocean"] .sea {
+ background:
+ radial-gradient(60rem 40rem at 10% -10%, rgb(170 70 150 / 0.14), transparent 70%),
+ radial-gradient(60rem 50rem at 40% 120%, rgb(10 0 25 / 0.6), transparent 70%);
+}
+
+main { position: relative; z-index: 1; }
+
+:focus-visible { outline: 2px solid var(--bronze); outline-offset: 3px; border-radius: 4px; }
+
+/* ---------- Opening ---------- */
+
+.opening {
+ min-height: 100svh;
+ padding: 14vh 2rem 8vh calc(var(--lx) + 2.6rem);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ max-width: 62rem;
+}
+.opening__kicker { margin: 0 0 1.5rem; color: var(--bronze); font-size: 0.9rem; letter-spacing: 0.02em; }
+.opening__title {
+ margin: 0;
+ font-family: var(--serif);
+ font-weight: 300;
+ font-size: clamp(3.2rem, 6.4vw, 6.6rem);
+ line-height: 0.92;
+ letter-spacing: -0.02em;
+}
+.opening__title span { display: block; padding-left: 0.9em; font-style: italic; color: var(--mist); }
+.opening__lede { max-width: 34rem; margin: 2.5rem 0 0; color: var(--mist); font-size: 1rem; }
+.opening__cue { margin: 3rem 0 0; height: 4.5rem; width: 1px; background: linear-gradient(var(--bronze), transparent); position: relative; overflow: hidden; }
+.opening__cue span {
+ position: absolute; left: 0; top: -40%; width: 1px; height: 40%;
+ background: var(--foam);
+ animation: cue 2.4s cubic-bezier(.6,0,.4,1) infinite;
+}
+@keyframes cue { to { top: 110%; } }
+
+/* ---------- Layout ---------- */
+
+.voyage {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(24rem, 36rem);
+ align-items: start;
+}
+
+/* ---------- The course ---------- */
+
+.course { position: relative; padding: 0 2rem 0 0; }
+.course__lane { position: relative; }
+
+.course__line {
+ position: absolute;
+ top: 0;
+ bottom: 30vh;
+ left: var(--lx);
+ width: 1px;
+ background: linear-gradient(to bottom, rgb(207 154 94 / 0.35), rgb(207 154 94 / 0.18) 80%, transparent);
+}
+.course__wake {
+ position: absolute; left: -1px; top: 0; width: 3px; height: 0;
+ background: linear-gradient(to bottom, rgb(207 154 94 / 0.2), var(--bronze));
+ box-shadow: 0 0 18px rgb(255 190 120 / 0.45);
+ border-radius: 2px;
+}
+.course__ship {
+ position: absolute; left: -7px; top: -7px; width: 15px; height: 15px;
+ border-radius: 50%;
+ background: var(--foam);
+ box-shadow: 0 0 0 5px rgb(241 230 212 / 0.12), 0 0 30px 6px rgb(255 200 140 / 0.5);
+ will-change: transform;
+}
+
+/* saga crossing */
+.saga {
+ position: relative;
+ padding: 14vh 0 12vh calc(var(--lx) + 2.5rem);
+}
+.saga::before {
+ content: "";
+ position: absolute;
+ left: calc(var(--lx) - 2.25rem);
+ top: calc(14vh + 1.9rem);
+ width: 4.5rem;
+ height: 1px;
+ background: var(--bronze);
+}
+.saga__order { display: block; color: var(--bronze); font-size: 0.85rem; margin-bottom: 0.4rem; }
+.saga__name {
+ margin: 0;
+ font-family: var(--serif);
+ font-weight: 300;
+ font-size: clamp(2.6rem, 5.5vw, 5rem);
+ line-height: 1;
+ letter-spacing: -0.01em;
+ color: transparent;
+ -webkit-text-stroke: 1px rgb(241 230 212 / 0.55);
+}
+
+/* time between songs: 30vh floor + 60vh per in-story year, ticked by month */
+.gap { position: relative; height: calc(30vh + var(--years) * 60vh); }
+.gap--years::before {
+ content: "";
+ position: absolute;
+ left: calc(var(--lx) - 6px);
+ top: 15vh;
+ bottom: 15vh;
+ width: 13px;
+ background: repeating-linear-gradient(to bottom, rgb(207 154 94 / 0.5) 0 1px, transparent 1px 5vh);
+ -webkit-mask: linear-gradient(transparent, #000 15%, #000 85%, transparent);
+ mask: linear-gradient(transparent, #000 15%, #000 85%, transparent);
+}
+.gap--years { display: flex; align-items: center; }
+.gap__label {
+ margin: 0;
+ width: calc(var(--lx) - 1.4rem);
+ text-align: right;
+ display: flex;
+ flex-direction: column;
+}
+.gap__phrase { font-family: var(--serif); font-style: italic; font-size: 1.35rem; line-height: 1.1; color: var(--foam); }
+.gap__scale { font-size: 0.75rem; color: var(--bronze); margin-top: 0.3rem; font-variant-numeric: tabular-nums; }
+
+/* a song on the line */
+.stop {
+ --veil: 1;
+ --passed: 0;
+ --dim: 1;
+ position: relative;
+ display: grid;
+ grid-template-columns: var(--lx) minmax(0, 1fr);
+ align-items: center;
+ min-height: 5rem;
+ opacity: calc((1 - var(--veil) * 0.94) * (1 - var(--passed) * 0.5) * var(--dim));
+ transition: opacity 0.5s ease;
+}
+.stop__year {
+ margin: 0;
+ padding-right: 1.6rem;
+ text-align: right;
+ font-family: var(--serif);
+ font-size: 1.15rem;
+ font-style: italic;
+ color: var(--bronze);
+}
+.stop__node {
+ position: absolute;
+ left: calc(var(--lx) - 9px);
+ top: 50%;
+ width: 19px;
+ height: 19px;
+ margin-top: -9.5px;
+ padding: 0;
+ border-radius: 50%;
+ border: 1px solid var(--bronze);
+ background: var(--bg);
+ cursor: pointer;
+ transition: transform 0.4s cubic-bezier(.2,.8,.2,1), background-color 0.4s, box-shadow 0.4s;
+}
+.stop__node::after {
+ content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--bronze-dim);
+ transition: background-color 0.4s;
+}
+.stop:hover .stop__node, .stop__node:focus-visible { transform: scale(1.35); }
+.stop.is-active .stop__node { transform: scale(1.5); box-shadow: 0 0 0 6px rgb(207 154 94 / 0.14); }
+.stop.is-active .stop__node::after { background: var(--foam); }
+
+.stop__body {
+ padding: 0.6rem 0 0.6rem 2.6rem;
+ cursor: pointer;
+ transform: translateX(calc(var(--veil) * 2.5rem));
+ filter: blur(calc(var(--veil) * 5px));
+ will-change: transform, filter;
+}
+.stop__track { margin: 0; font-size: 0.8rem; color: var(--bronze); font-variant-numeric: tabular-nums; }
+.stop__title {
+ margin: 0.1rem 0 0.25rem;
+ font-family: var(--serif);
+ font-weight: 300;
+ font-style: italic;
+ font-size: clamp(2.4rem, 4.6vw, 4.4rem);
+ line-height: 0.98;
+ letter-spacing: -0.015em;
+ color: var(--mist);
+ transition: color 0.5s ease;
+}
+.stop:hover .stop__title { color: var(--foam); }
+.stop.is-active .stop__title { color: var(--foam); }
+.stop__when { margin: 0; color: var(--mist); font-size: 0.92rem; }
+.stop__pips { display: flex; gap: 4px; margin-top: 0.8rem; }
+.pip {
+ width: 1.8rem; height: 3px; border-radius: 2px;
+ background: rgb(241 230 212 / 0.22);
+ transition: background-color 0.4s, box-shadow 0.4s;
+}
+
+/* motif selected: matching songs keep their light, the rest sink */
+body[data-motif] .stop:not(.is-hit) { --dim: 0.28; }
+/* a followed motif overrides the veil: every song where it sounds stays lit, ahead or behind the ship */
+body[data-motif] .stop.is-hit { --veil: 0 !important; --passed: 0 !important; }
+body[data-motif] .stop.is-hit .stop__node { border-color: var(--sel); box-shadow: 0 0 0 4px color-mix(in srgb, var(--sel) 25%, transparent), 0 0 22px color-mix(in srgb, var(--sel) 70%, transparent); }
+body[data-motif] .stop.is-hit .stop__node::after { background: var(--sel); }
+body[data-motif] .stop.is-hit .stop__title { color: var(--foam); }
+.pip.is-hit { background: var(--sel); box-shadow: 0 0 10px color-mix(in srgb, var(--sel) 80%, transparent); }
+
+.coda { padding: 20vh 0 34vh calc(var(--lx) + 2.6rem); }
+.coda__text { margin: 0; font-family: var(--serif); font-style: italic; font-size: 1.6rem; line-height: 1.25; color: var(--mist); }
+
+/* ---------- Sticky detail panel ---------- */
+
+.panel {
+ position: sticky;
+ top: 0;
+ height: 100svh;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: var(--bronze-dim) transparent;
+ background: linear-gradient(to right, rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.34));
+ border-left: 1px solid rgb(207 154 94 / 0.22);
+ backdrop-filter: blur(6px);
+}
+
+.rail {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ padding: 1.1rem 1.75rem 0.9rem;
+ background: color-mix(in srgb, var(--bg) 70%, #000);
+ border-bottom: 1px solid rgb(207 154 94 / 0.18);
+ box-shadow: 0 1.2rem 1.2rem -0.6rem color-mix(in srgb, var(--bg) 70%, #000);
+}
+.rail__label { margin: 0 0 0.55rem; font-size: 0.8rem; color: var(--mist); }
+.rail__chips { display: flex; flex-direction: column; gap: 0.4rem; }
+.rail__group { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
+.chip--rail[data-kind="character"] .chip__dot { background: transparent; box-shadow: inset 0 0 0 2px var(--mc); }
+.rail__map { display: flex; align-items: center; height: 1.4rem; margin-top: 0.7rem; }
+.map__tick {
+ flex: none;
+ width: 0.5rem;
+ height: 0.9rem;
+ margin-left: calc(0.3rem + var(--years) * 1.6rem);
+ padding: 0;
+ border: 0;
+ border-radius: 2px;
+ background: rgb(241 230 212 / 0.2);
+ cursor: pointer;
+ transition: background-color 0.3s, box-shadow 0.3s, height 0.3s;
+}
+.map__tick:first-child { margin-left: 0; }
+.map__tick--saga { margin-left: calc(1rem + var(--years) * 1.6rem); }
+.map__tick:hover { background: rgb(241 230 212 / 0.55); }
+.map__tick.is-here { height: 1.4rem; background: var(--foam); }
+body[data-motif] .map__tick.is-hit { background: var(--sel); box-shadow: 0 0 8px color-mix(in srgb, var(--sel) 70%, transparent); }
+body[data-motif] .map__tick.is-hit.is-here { box-shadow: 0 0 0 2px var(--foam), 0 0 10px var(--sel); }
+.rail__note { margin: 0.6rem 0 0; min-height: 2.7em; font-size: 0.8rem; color: var(--mist); line-height: 1.35; }
+
+.chip {
+ --mc: var(--bronze);
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.22rem 0.6rem 0.22rem 0.45rem;
+ border: 1px solid color-mix(in srgb, var(--mc) 45%, transparent);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--foam);
+ font: 500 0.76rem/1.2 var(--sans);
+ cursor: pointer;
+ transition: background-color 0.25s, border-color 0.25s, color 0.25s;
+}
+.chip__dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--mc); }
+.chip:hover { border-color: var(--mc); }
+.chip.is-on { background: var(--mc); border-color: var(--mc); color: #1a0d10; }
+.chip.is-on .chip__dot { background: #1a0d10; box-shadow: none; }
+
+.panel__stage { position: relative; padding: 0.5rem 1.75rem 3rem; }
+
+.detail { animation: detail-in 0.7s cubic-bezier(.2,.75,.2,1) both; }
+.detail.is-leaving {
+ position: absolute;
+ inset: 0.5rem 1.75rem auto;
+ pointer-events: none;
+ animation: detail-out 0.35s ease both;
+}
+@keyframes detail-in {
+ from { opacity: 0; transform: translateY(1.5rem); filter: blur(6px); }
+ to { opacity: 1; transform: none; filter: none; }
+}
+@keyframes detail-out {
+ to { opacity: 0; transform: translateY(-1rem); filter: blur(4px); }
+}
+
+.detail__where { margin: 0.6rem 0 0; color: var(--bronze); font-size: 0.85rem; }
+.detail__title {
+ margin: 0.15rem 0 1.4rem;
+ font-family: var(--serif);
+ font-weight: 300;
+ font-size: clamp(2.6rem, 3.6vw, 3.6rem);
+ line-height: 0.95;
+ letter-spacing: -0.015em;
+}
+
+.detail__facts { margin: 0; display: grid; gap: 0.9rem; padding: 1rem 0; border-top: 1px solid rgb(207 154 94 / 0.25); border-bottom: 1px solid rgb(207 154 94 / 0.25); }
+.fact-row { display: grid; grid-template-columns: 7.2rem 1fr; gap: 1rem; }
+.fact-row dt { font-size: 0.8rem; color: var(--mist); padding-top: 0.2rem; }
+.fact-row dd { margin: 0; display: flex; flex-direction: column; }
+.detail__year { font-family: var(--serif); font-size: 1.3rem; font-weight: 500; line-height: 1.2; font-variant-numeric: lining-nums; }
+.detail__label { color: var(--mist); font-size: 0.85rem; }
+.detail__ref { font-family: var(--serif); font-size: 1.3rem; font-weight: 500; line-height: 1.2; color: var(--foam); text-decoration-color: var(--bronze); text-underline-offset: 3px; font-variant-numeric: lining-nums; }
+.detail__ref:hover { color: var(--bronze); }
+
+.detail__h { margin: 1.8rem 0 0.7rem; font-size: 0.8rem; font-weight: 500; color: var(--bronze); }
+
+.parts { list-style: none; margin: 0; padding: 0; counter-reset: part; display: grid; gap: 0.4rem; }
+.part {
+ position: relative;
+ padding: 0.75rem 0 0.85rem 1.1rem;
+ border-left: 2px solid rgb(241 230 212 / 0.14);
+ transition: border-color 0.35s, opacity 0.35s, background-color 0.35s;
+}
+.part__top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2rem 0.75rem; }
+.part__name { font-weight: 600; font-size: 0.92rem; }
+.part__singers { color: var(--mist); font-size: 0.85rem; }
+.part__lines { margin: 0.4rem 0 0.6rem; font-family: var(--serif); font-style: italic; font-size: 1.1rem; line-height: 1.4; color: rgb(241 230 212 / 0.78); }
+.part__lines p { margin: 0; }
+.part__chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
+body[data-motif] .part { opacity: 0.4; }
+body[data-motif] .part.is-hit {
+ opacity: 1;
+ border-left-color: var(--sel);
+ background: linear-gradient(to right, color-mix(in srgb, var(--sel) 14%, transparent), transparent);
+}
+
+.detail__fact { margin: 0; font-family: var(--serif); font-size: 1.25rem; line-height: 1.4; max-width: 30em; }
+
+/* shared modules, restyled for the voyage */
+.panel .audio-slot {
+ border: 1px dashed rgb(207 154 94 / 0.45);
+ border-radius: 10px;
+ padding: 0.9rem 1rem;
+ color: var(--mist);
+ font-size: 0.88rem;
+}
+.panel .audio-slot--empty { opacity: 1; }
+.panel .audio-slot--empty::before { content: "♪"; margin-right: 0.6rem; color: var(--bronze); }
+.panel .video-cards { display: grid; gap: 0.5rem; }
+.panel .video-card {
+ padding: 0.85rem 1rem;
+ border: 1px solid rgb(241 230 212 / 0.16);
+ border-radius: 10px;
+ background: rgb(241 230 212 / 0.04);
+ font-size: 0.9rem;
+ transition: border-color 0.25s, background-color 0.25s;
+}
+.panel .video-card::before {
+ content: "";
+ display: inline-block;
+ margin-right: 0.7rem;
+ border-left: 0.55rem solid var(--bronze);
+ border-top: 0.35rem solid transparent;
+ border-bottom: 0.35rem solid transparent;
+}
+.panel .video-card:hover { opacity: 1; border-color: var(--bronze); background: rgb(207 154 94 / 0.08); }
+
+/* credits */
+.fan-credits {
+ position: relative;
+ z-index: 1;
+ padding: 2rem 1.5rem 2.5rem;
+ border-top: 1px solid rgb(207 154 94 / 0.2);
+ color: var(--mist);
+ font-size: 0.8rem;
+ opacity: 1;
+}
+
+/* ---------- Small screens: panel becomes a bottom sheet ---------- */
+
+@media (max-width: 860px) {
+ :root { --lx: 4.2rem; }
+ .voyage { grid-template-columns: minmax(0, 1fr); }
+ .course { padding-right: 1rem; }
+ .stop__title { font-size: 2.2rem; }
+ .stop__body { padding-left: 1.6rem; }
+ .stop__year { padding-right: 1.1rem; font-size: 1rem; }
+ .gap__phrase { font-size: 1.05rem; }
+ .panel {
+ top: auto;
+ bottom: 0;
+ height: 52svh;
+ border-left: 0;
+ border-top: 1px solid rgb(207 154 94 / 0.35);
+ background: color-mix(in srgb, var(--bg) 82%, #000);
+ }
+ .rail { padding: 0.8rem 1rem 0.6rem; }
+ .rail__chips { flex-direction: row; overflow-x: auto; flex-wrap: nowrap; }
+ .rail__group { flex-wrap: nowrap; }
+ .chip { white-space: nowrap; flex-shrink: 0; }
+ .opening { padding: 10vh 1.25rem 6vh 1.5rem; }
+ .rail__note { font-size: 0.75rem; }
+ .rail__note { min-height: 0; }
+ .panel__stage { padding: 0.25rem 1rem 2rem; }
+ .detail.is-leaving { inset: 0.25rem 1rem auto; }
+ .fact-row { grid-template-columns: 6rem 1fr; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
+ .stop__body { filter: none; transform: none; }
+}