diff --git a/src/mockups/03-transit-map/main.ts b/src/mockups/03-transit-map/main.ts
index cbd9e18..bf6a8d3 100644
--- a/src/mockups/03-transit-map/main.ts
+++ b/src/mockups/03-transit-map/main.ts
@@ -1,5 +1,368 @@
+import { motifs, sagas, songs } from "../../data/sample";
+import { motifsInSong, sagaOf } 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";
+import { GAP, LINE_W, buildLayout, type Layout, type Station } from "./map";
-mountCredits();
+const credits = mountCredits();
+// The station board ends above the footer so the fan credit is never covered.
+const syncFooter = () => document.documentElement.style.setProperty("--footer-h", `${credits.offsetHeight}px`);
+syncFooter();
+addEventListener("resize", syncFooter);
+
+const esc = (s: string) =>
+ s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
+const pad = (n: number) => String(n).padStart(2, "0");
+const motifById = new Map(motifs.map((m) => [m.id, m]));
+const songById = new Map(songs.map((s) => [s.id, s]));
+const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
+const layout: Layout = buildLayout(songs, motifs, sagas);
+const yearLabel = (y: number) => (y === 0 ? "The fall of Troy" : `Year ${y} after Troy`);
+
+let activeLine: string | null = null;
+let openSong: string | null = null;
+
+// ---------- shell ----------
const app = document.getElementById("app")!;
-app.innerHTML = `
Transit map
stub
`;
+app.innerHTML = `
+
+
+ Drag or scroll sideways to travel. Click a station to see its board.
+
+
+
+
+`;
+const scroller = app.querySelector(".network__scroll")!;
+const sign = app.querySelector(".sign")!;
+const board = app.querySelector(".board")!;
+const route = app.querySelector(".route")!;
+
+// ---------- legend line buttons ----------
+for (const m of motifs) {
+ const ul = app.querySelector(`ul[data-group="${m.kind}"]`)!;
+ const count = layout.lines.find((l) => l.motif.id === m.id)!.stations.length;
+ const li = document.createElement("li");
+ li.innerHTML = ``;
+ ul.append(li);
+}
+
+// ---------- the map ----------
+function renderMap(): string {
+ const { width, height, stations, lines, zones, gaps, laneTop, rulerY } = layout;
+ const zoneSvg = zones.map((z) => `
+
+
+
+ ${z.index}
+ Zone ${z.index}
+ ${esc(z.saga.name)}
+
+ `).join("");
+
+ const lineSvg = lines.filter((l) => l.d).map((l) => `
+
+
+
+ ${esc(l.motif.name)} line
+ `).join("");
+
+ const stationSvg = stations.map((st) => {
+ const n = st.stack.length;
+ const h = st.bottom - st.top + GAP;
+ const shape = n > 1
+ ? ``
+ : ``;
+ const ids = st.stack.map((s) => s.motif.id).join(" ");
+ const story = yearLabel(st.song.storyTime.yearsAfterTroy);
+ return `
+
+
+
+ ${shape}
+
+ ${pad(st.song.track)}${esc(st.song.title)}
+
+ `;
+ }).join("");
+
+ // Ruler: in-story time. Station spacing grows with the years that pass between songs.
+ const first = stations[0]!.x, last = stations[stations.length - 1]!.x;
+ let prevYear = -1;
+ const ticks = stations.map((st) => {
+ const y = st.song.storyTime.yearsAfterTroy;
+ const label = y !== prevYear
+ ? `${esc(yearLabel(y))}`
+ : `Year ${y}`;
+ prevYear = y;
+ return `${label}`;
+ }).join("");
+ const gapSvg = gaps.map((g) => {
+ const mid = (g.x0 + g.x1) / 2;
+ const txt = `${g.years} year${g.years > 1 ? "s" : ""} pass${g.years > 1 ? "" : "es"}`;
+ return `
+
+
+ ${txt}`;
+ }).join("");
+
+ return ``;
+}
+scroller.innerHTML = renderMap();
+const svg = scroller.querySelector("svg")!;
+svg.style.setProperty("--lw", String(LINE_W));
+if (!reducedMotion) {
+ svg.classList.add("map--intro");
+ setTimeout(() => svg.classList.remove("map--intro"), 2400);
+}
+
+// ---------- state + rendering ----------
+function chip(m: Motif): string {
+ return ``;
+}
+
+function applyLine() {
+ svg.classList.toggle("has-line", !!activeLine);
+ svg.querySelectorAll(".line").forEach((g) => g.classList.toggle("is-on", g.dataset.line === activeLine));
+ svg.querySelectorAll(".station").forEach((g) =>
+ g.classList.toggle("is-served", !!activeLine && (g.dataset.lines ?? "").split(" ").includes(activeLine)));
+ app.querySelectorAll(".line-btn").forEach((b) => {
+ const on = b.dataset.line === activeLine;
+ b.classList.toggle("is-on", on);
+ b.setAttribute("aria-pressed", String(on));
+ });
+ board.querySelectorAll(".chip").forEach((c) => {
+ const on = c.dataset.line === activeLine;
+ c.classList.toggle("is-on", on);
+ c.setAttribute("aria-pressed", String(on));
+ });
+ board.classList.toggle("has-line", !!activeLine);
+ board.querySelectorAll(".platform").forEach((p) =>
+ p.classList.toggle("is-served", !!activeLine && (p.dataset.lines ?? "").split(" ").includes(activeLine)));
+
+ // Train: one small car rides the selected line.
+ const trainLayer = svg.querySelector(".train-layer")!;
+ trainLayer.innerHTML = "";
+ if (activeLine && !reducedMotion) {
+ const track = svg.querySelector(`#track-${activeLine}`);
+ const len = track?.getTotalLength?.() ?? 1200;
+ const dur = Math.max(6, len / 230).toFixed(1);
+ trainLayer.innerHTML = `
+
+ `;
+ }
+
+ if (!activeLine) { route.hidden = true; route.innerHTML = ""; return; }
+ const m = motifById.get(activeLine)!;
+ const geom = layout.lines.find((l) => l.motif.id === activeLine)!;
+ route.hidden = false;
+ route.style.setProperty("--c", m.color);
+ route.innerHTML = `
+
+
${esc(m.name)} line
+
+ ${esc(m.description)}
+ Calls at ${geom.stations.length} stations
+ ${geom.stations.map((s) =>
+ ``).join("")}
`;
+}
+
+function setLine(id: string | null) {
+ activeLine = activeLine === id ? null : id;
+ applyLine();
+}
+
+function renderBoard(song: Song) {
+ const saga = sagaOf(song);
+ const zone = layout.zones.find((z) => z.saga.id === saga.id)!;
+ const lines = motifsInSong(song);
+ const prev = songs.find((s) => s.track === song.track - 1);
+ const next = songs.find((s) => s.track === song.track + 1);
+ const odyssey = song.odyssey
+ ? `
+ Homer's Odyssey, book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}`
+ : `No counterpart in Homer's Odyssey
`;
+ board.innerHTML = `
+ ${lines.map((m) => ``).join("")}
+
+
+ - Story time
- ${esc(yearLabel(song.storyTime.yearsAfterTroy))}${esc(song.storyTime.label)}
+ - Source
- ${odyssey}
+
+ Lines calling here
${lines.map(chip).join("")}
+ Platforms
+ ${song.parts.map((p, i) => `
+ -
+
${String.fromCharCode(65 + i)}
+
+
${esc(p.name)} sung by ${esc(p.singers.join(" and "))}
+
${p.lines.map((l) => `${esc(l)}
`).join("")}
+
${p.motifIds.map((id) => motifById.get(id)).filter((m): m is Motif => !!m).map(chip).join("")}
+
+ `).join("")}
+ Worth knowing
${song.facts.map((f) => `${esc(f)}
`).join("")}
+
+ `;
+ board.querySelector(".board__watch")!.append(createVideoCards(song));
+ board.querySelector(".board__listen")!.append(createAudioSlot(song));
+}
+
+function setSong(id: string | null, focusBoard = false, instant = false) {
+ openSong = id;
+ svg.querySelectorAll(".station").forEach((g) => g.classList.toggle("is-open", g.dataset.song === id));
+ document.body.classList.toggle("board-open", !!id);
+ board.setAttribute("aria-hidden", String(!id));
+ if (!id) return;
+ const song = songById.get(id)!;
+ renderBoard(song);
+ applyLine();
+ board.scrollTop = 0;
+ const st = layout.stations.find((s) => s.song.id === id)!;
+ const visible = scroller.clientWidth - (innerWidth > 900 ? board.offsetWidth : 0);
+ scroller.scrollTo({ left: st.x * scale() - visible * 0.6, behavior: reducedMotion || instant ? "auto" : "smooth" });
+ if (focusBoard) board.querySelector(".board__close")?.focus();
+}
+
+let fitScale = 1;
+const scale = () => fitScale;
+// Fit the diagram to the viewport height (gently, so stations stay legible) and let zones bleed top to bottom.
+function fit() {
+ const H = scroller.clientHeight;
+ if (!H) return;
+ fitScale = Math.min(1.15, Math.max(0.8, H / layout.height));
+ const vbH = H / fitScale;
+ const pad = (vbH - layout.height) / 2;
+ svg.setAttribute("viewBox", `0 ${-pad} ${layout.width} ${vbH}`);
+ svg.setAttribute("height", String(H));
+ svg.setAttribute("width", String(Math.round(layout.width * fitScale)));
+}
+fit();
+addEventListener("resize", fit);
+
+// ---------- events ----------
+app.addEventListener("click", (e) => {
+ const t = e.target as Element;
+ const lineBtn = t.closest("[data-line]:is(button, .line)");
+ if (lineBtn) return setLine(lineBtn.dataset.line!);
+ if (t.closest(".route__clear")) return setLine(activeLine);
+ if (t.closest(".board__close")) return setSong(null);
+ const songBtn = t.closest("[data-song]");
+ if (songBtn?.dataset.song && !dragged) return setSong(songBtn.dataset.song);
+});
+svg.addEventListener("keydown", (e) => {
+ const st = (e.target as Element).closest(".station");
+ if (st && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); setSong(st.dataset.song!, true); }
+});
+document.addEventListener("keydown", (e) => {
+ if (e.key !== "Escape") return;
+ if (openSong) setSong(null);
+ else if (activeLine) setLine(activeLine);
+});
+
+// Hover sign
+function showSign(st: Station, el: Element) {
+ const r = el.querySelector(".stop")!.getBoundingClientRect();
+ const host = sign.parentElement!.getBoundingClientRect();
+ sign.innerHTML = `${esc(st.song.title)}${esc(st.song.storyTime.label)}
+ ${st.stack.map((s) => ``).join("")}
+ ${st.stack.length > 1 ? `Interchange, ${st.stack.length} lines` : "1 line"}`;
+ sign.hidden = false;
+ sign.style.left = `${r.left + r.width / 2 - host.left}px`;
+ sign.style.top = `${r.bottom - host.top + 14}px`;
+}
+svg.querySelectorAll(".station").forEach((g) => {
+ const st = layout.stations.find((s) => s.song.id === g.dataset.song)!;
+ g.addEventListener("pointerenter", () => showSign(st, g));
+ g.addEventListener("focus", () => showSign(st, g));
+ g.addEventListener("pointerleave", () => (sign.hidden = true));
+ g.addEventListener("blur", () => (sign.hidden = true));
+});
+scroller.addEventListener("scroll", () => (sign.hidden = true), { passive: true });
+
+// Drag to pan, vertical wheel travels sideways.
+let dragged = false;
+let drag: { x: number; left: number; id: number } | null = null;
+scroller.addEventListener("pointerdown", (e) => {
+ if (e.button !== 0 || e.pointerType === "touch") return;
+ drag = { x: e.clientX, left: scroller.scrollLeft, id: e.pointerId };
+ dragged = false;
+});
+scroller.addEventListener("pointermove", (e) => {
+ if (!drag) return;
+ const dx = e.clientX - drag.x;
+ if (!dragged && Math.abs(dx) > 4) { dragged = true; scroller.setPointerCapture(drag.id); scroller.classList.add("is-dragging"); }
+ if (dragged) scroller.scrollLeft = drag.left - dx;
+});
+const endDrag = () => { drag = null; scroller.classList.remove("is-dragging"); setTimeout(() => (dragged = false), 0); };
+scroller.addEventListener("pointerup", endDrag);
+scroller.addEventListener("pointercancel", endDrag);
+scroller.addEventListener("wheel", (e) => {
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX) && !e.shiftKey) { scroller.scrollLeft += e.deltaY; e.preventDefault(); }
+}, { passive: false });
+
+// ---------- initial state (query params exist only so screenshots can open a state) ----------
+const params = new URLSearchParams(location.search);
+const qLine = params.get("line");
+if (qLine && motifById.has(qLine)) setLine(qLine);
+const qSong = params.get("song");
+if (qSong && songById.has(qSong)) {
+ if (!reducedMotion) svg.classList.remove("map--intro");
+ requestAnimationFrame(() => setSong(qSong, false, true));
+}
diff --git a/src/mockups/03-transit-map/map.ts b/src/mockups/03-transit-map/map.ts
new file mode 100644
index 0000000..861663a
--- /dev/null
+++ b/src/mockups/03-transit-map/map.ts
@@ -0,0 +1,118 @@
+import { gapYears } from "../../data/query";
+import type { Motif, Saga, Song } from "../../data/types";
+
+// Network-map geometry. Every motif is a line with a "home lane": character themes idle north of the
+// trunk, musical motifs idle south of it. At a station the lines that call there squeeze into one
+// stack centred on the trunk, so the white interchange capsule covers exactly the lines that stop.
+export const GAP = 20; // distance between parallel lines
+export const LINE_W = 11;
+export const STEP = 168; // base spacing between consecutive songs
+export const YEAR = 260; // extra spacing per in-story year between songs
+export const LEFT = 150;
+export const TRUNK = 300;
+
+export interface Station {
+ song: Song;
+ x: number;
+ stack: { motif: Motif; y: number }[];
+ top: number;
+ bottom: number;
+}
+export interface LineGeom { motif: Motif; d: string; stations: Song[]; north: boolean }
+export interface Zone { saga: Saga; x0: number; x1: number; index: number }
+export interface Gap { x0: number; x1: number; years: number }
+export interface Layout {
+ width: number; height: number; stations: Station[]; lines: LineGeom[]; zones: Zone[]; gaps: Gap[];
+ laneTop: number; laneBottom: number; rulerY: number;
+}
+
+const motifIdsOf = (s: Song) => new Set(s.parts.flatMap((p) => p.motifIds));
+
+export function buildLayout(songs: Song[], motifs: Motif[], sagas: Saga[]): Layout {
+ const ordered = [...songs].sort((a, b) => a.track - b.track);
+ const north = motifs.filter((m) => m.kind === "character");
+ const south = motifs.filter((m) => m.kind !== "character");
+ const maxStack = Math.max(...ordered.map((s) => motifIdsOf(s).size));
+ const stackHalf = ((maxStack - 1) / 2) * GAP;
+ const home = new Map();
+ north.forEach((m, k) => home.set(m.id, TRUNK - stackHalf - GAP * (1.75 + k)));
+ south.forEach((m, k) => home.set(m.id, TRUNK + stackHalf + GAP * (1.75 + k)));
+ // Stack order: outermost northern line on top, outermost southern line at the bottom.
+ const rank = new Map();
+ [...north].reverse().forEach((m, i) => rank.set(m.id, i));
+ south.forEach((m, i) => rank.set(m.id, north.length + i));
+
+ const xs: number[] = [];
+ const gaps: Gap[] = [];
+ ordered.forEach((s, i) => {
+ if (i === 0) return void xs.push(LEFT);
+ const years = gapYears(ordered[i - 1]!, s);
+ const x = xs[i - 1]! + STEP + YEAR * Math.max(0, years);
+ if (years > 0) gaps.push({ x0: xs[i - 1]!, x1: x, years });
+ xs.push(x);
+ });
+
+ const stations: Station[] = ordered.map((song, i) => {
+ const ids = motifIdsOf(song);
+ const present = motifs.filter((m) => ids.has(m.id)).sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
+ const stack = present.map((motif, j) => ({ motif, y: TRUNK + (j - (present.length - 1) / 2) * GAP }));
+ const ys = stack.map((s) => s.y);
+ return { song, x: xs[i]!, stack, top: Math.min(...ys), bottom: Math.max(...ys) };
+ });
+
+ const lines: LineGeom[] = motifs.map((motif) => {
+ const yAt = (st: Station) => st.stack.find((s) => s.motif.id === motif.id)?.y ?? home.get(motif.id)!;
+ const serving = stations.filter((st) => st.stack.some((s) => s.motif.id === motif.id));
+ if (serving.length === 0) return { motif, d: "", stations: [], north: motif.kind === "character" };
+ const first = stations.indexOf(serving[0]!);
+ const last = stations.indexOf(serving[serving.length - 1]!);
+ const pts: [number, number][] = [[stations[first]!.x - (serving.length === 1 ? 40 : 0), yAt(stations[first]!)]];
+ for (let i = first; i < last; i++) {
+ const a = stations[i]!, b = stations[i + 1]!;
+ const ya = yAt(a), yb = yAt(b);
+ if (ya !== yb) {
+ const dy = Math.abs(yb - ya);
+ const mid = (a.x + b.x) / 2;
+ pts.push([mid - dy / 2, ya], [mid + dy / 2, yb]);
+ }
+ pts.push([b.x, yb]);
+ }
+ if (serving.length === 1) pts.push([stations[first]!.x + 40, yAt(stations[first]!)]);
+ return { motif, d: roundedPath(pts, 14), stations: serving.map((s) => s.song), north: motif.kind === "character" };
+ });
+
+ const zones: Zone[] = [...sagas].sort((a, b) => a.order - b.order).flatMap((saga, index) => {
+ const idx = stations.map((s, i) => (s.song.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
+ if (idx.length === 0) return [];
+ const fi = idx[0]!, li = idx[idx.length - 1]!;
+ const x0 = fi === 0 ? 0 : (stations[fi - 1]!.x + stations[fi]!.x) / 2;
+ const lastX = stations[stations.length - 1]!.x;
+ const x1 = li === stations.length - 1 ? lastX + LEFT + 40 : (stations[li]!.x + stations[li + 1]!.x) / 2;
+ return [{ saga, x0, x1, index: index + 1 }];
+ });
+
+ const laneTop = TRUNK - stackHalf - GAP * (1.75 + north.length - 1);
+ const laneBottom = TRUNK + stackHalf + GAP * (1.75 + south.length - 1);
+ const rulerY = laneBottom + 64;
+ return {
+ width: stations[stations.length - 1]!.x + LEFT + 40, height: rulerY + 130,
+ stations, lines, zones, gaps, laneTop, laneBottom, rulerY,
+ };
+}
+
+/** Polyline with rounded corners, the way a drafted transit map bends its lines. */
+export function roundedPath(pts: [number, number][], r: number): string {
+ const p = pts.filter((q, i) => i === 0 || q[0] !== pts[i - 1]![0] || q[1] !== pts[i - 1]![1]);
+ if (p.length < 2) return "";
+ let d = `M${p[0]![0]},${p[0]![1]}`;
+ for (let i = 1; i < p.length - 1; i++) {
+ const [ax, ay] = p[i - 1]!, [bx, by] = p[i]!, [cx, cy] = p[i + 1]!;
+ const l1 = Math.hypot(bx - ax, by - ay), l2 = Math.hypot(cx - bx, cy - by);
+ const rr = Math.min(r, l1 / 2, l2 / 2);
+ const s: [number, number] = [bx - ((bx - ax) / l1) * rr, by - ((by - ay) / l1) * rr];
+ const e: [number, number] = [bx + ((cx - bx) / l2) * rr, by + ((cy - by) / l2) * rr];
+ d += ` L${s[0].toFixed(1)},${s[1].toFixed(1)} Q${bx},${by} ${e[0].toFixed(1)},${e[1].toFixed(1)}`;
+ }
+ const z = p[p.length - 1]!;
+ return d + ` L${z[0]},${z[1]}`;
+}
diff --git a/src/mockups/03-transit-map/style.css b/src/mockups/03-transit-map/style.css
index 5c9057d..be0345d 100644
--- a/src/mockups/03-transit-map/style.css
+++ b/src/mockups/03-transit-map/style.css
@@ -1,2 +1,296 @@
-main { padding: 2rem; font-family: system-ui, sans-serif; }
-h1::before { content: "03 · "; }
+/* 03 Transit map: a night-time network diagram of the voyage, with enamel station boards. */
+@import "@fontsource/inter/latin-400.css";
+@import "@fontsource/inter/latin-500.css";
+@import "@fontsource/inter/latin-700.css";
+@import "@fontsource/cormorant-garamond/latin-500-italic.css";
+:root {
+ --ink: #0f2335; /* map ground, Aegean at night */
+ --ink-zone: #132b41; /* alternating fare zone */
+ --ink-deep: #0a1a29; /* key column */
+ --rule: #27445e;
+ --text: #e3ebef;
+ --muted: #8ea4b6;
+ --enamel: #f1f3ee; /* station board */
+ --enamel-ink: #10263a;
+ --enamel-rule: #cfd6d3;
+ --stop: #f7f9f6;
+ --sans: "Inter", system-ui, sans-serif;
+ --serif: "Cormorant Garamond", Georgia, serif;
+ --board-w: 440px;
+ color-scheme: dark;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; height: 100%; }
+body {
+ background: var(--ink);
+ color: var(--text);
+ font-family: var(--sans);
+ font-size: 15px;
+ line-height: 1.5;
+ font-feature-settings: "tnum" 1, "cv11" 1;
+ display: grid;
+ grid-template-rows: 1fr auto;
+ overflow: hidden;
+}
+button { font: inherit; color: inherit; cursor: pointer; }
+:focus-visible { outline: 2px solid #ffd977; outline-offset: 3px; }
+
+#app {
+ display: grid;
+ grid-template-columns: 312px 1fr;
+ min-height: 0;
+}
+
+/* ---------- key column ---------- */
+.key {
+ background: var(--ink-deep);
+ border-right: 1px solid var(--rule);
+ padding: 26px 22px 22px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 26px;
+}
+.brand { display: grid; grid-template-columns: 40px 1fr; gap: 14px; align-items: start; }
+.brand__mark { width: 40px; height: 40px; color: var(--stop); }
+.brand h1 { margin: 0; font-size: 21px; line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; }
+.brand p { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.45; }
+
+.lines h2 {
+ margin: 0 0 8px; font-size: 13px; font-weight: 700; color: var(--text);
+ display: flex; justify-content: space-between; align-items: baseline;
+}
+.lines h2 span { font-weight: 400; color: var(--muted); font-size: 12px; }
+.lines ul { list-style: none; margin: 0 0 18px; padding: 0; display: grid; gap: 2px; }
+.line-btn {
+ width: 100%; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 12px;
+ background: none; border: 0; border-radius: 4px; padding: 7px 8px; text-align: left;
+ transition: background-color .15s;
+}
+.line-btn:hover { background: #ffffff0d; }
+.line-btn__bar { height: 9px; border-radius: 5px; background: var(--c); box-shadow: 0 0 0 2px var(--ink-deep); }
+.line-btn__name { font-size: 14.5px; font-weight: 500; }
+.line-btn__count {
+ font-size: 12px; color: var(--muted); min-width: 22px; height: 22px; border-radius: 11px;
+ display: grid; place-items: center; border: 1px solid var(--rule);
+}
+.line-btn.is-on { background: color-mix(in srgb, var(--c) 16%, transparent); }
+.line-btn.is-on .line-btn__count { background: var(--c); color: var(--ink-deep); border-color: var(--c); font-weight: 700; }
+
+.route {
+ border-left: 6px solid var(--c); padding: 2px 0 2px 14px;
+ animation: route-in .25s ease-out;
+}
+@keyframes route-in { from { opacity: 0; transform: translateX(-6px); } }
+.route__head { display: grid; justify-items: start; gap: 2px; }
+.route__bar { display: none; }
+.route h2 { margin: 0; font-size: 16px; font-weight: 700; }
+.route__head .route__clear { grid-row: 3; margin-top: 4px; }
+.route__clear { background: none; border: 0; padding: 0; font-size: 12.5px; color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
+.route__clear:hover { color: var(--text); }
+.route__desc { margin: 6px 0 4px; font-size: 13.5px; color: var(--text); }
+.route__meta { margin: 0 0 8px; font-size: 12.5px; color: var(--muted); }
+.route__stops { list-style: none; margin: 0; padding: 0; position: relative; }
+.route__stops::before { content: ""; position: absolute; left: 9px; top: 10px; bottom: 10px; width: 4px; background: var(--c); border-radius: 2px; }
+.route__stops button {
+ position: relative; display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
+ background: none; border: 0; padding: 3px 0; font-size: 13.5px;
+}
+.route__stops button::before {
+ content: ""; width: 14px; height: 14px; border-radius: 50%; flex: none; margin-left: 4px;
+ background: var(--stop); border: 3px solid var(--ink-deep);
+}
+.route__stops button:hover { text-decoration: underline; text-underline-offset: 3px; }
+.route__num { order: 2; color: var(--muted); font-size: 12px; }
+
+.legend { margin-top: auto; border-top: 1px solid var(--rule); padding-top: 16px; }
+.legend dl { margin: 0; display: grid; gap: 8px; }
+.legend dl > div { display: grid; grid-template-columns: 44px 1fr; gap: 12px; align-items: center; }
+.legend dt svg { width: 44px; height: 32px; display: block; overflow: visible; }
+.legend dd { margin: 0; font-size: 12.5px; color: var(--muted); }
+.legend .stop { fill: var(--stop); stroke: var(--ink-deep); stroke-width: 3; }
+.map .stop { fill: var(--stop); stroke: var(--ink); stroke-width: 4; }
+.legend .zone-swatch { fill: none; stroke: var(--text); stroke-width: 2; }
+.legend text { fill: var(--text); font-size: 13px; font-weight: 700; text-anchor: middle; }
+.legend .gap-swatch { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 2 4; stroke-linecap: round; }
+
+/* ---------- network ---------- */
+.network { position: relative; min-width: 0; min-height: 0; display: grid; }
+.network__hint {
+ position: absolute; left: 24px; top: 18px; z-index: 2; pointer-events: none;
+ font-size: 12.5px; color: var(--muted);
+}
+.network__scroll {
+ overflow-x: auto; overflow-y: hidden; display: flex; align-items: center;
+ cursor: grab; scrollbar-color: var(--rule) transparent; overscroll-behavior-x: contain;
+}
+.network__scroll.is-dragging { cursor: grabbing; user-select: none; }
+.network__scroll:focus-visible { outline-offset: -4px; }
+.map { display: block; flex: none; user-select: none; }
+.sign[hidden] { display: none; }
+@media (min-width: 901px) { .board-open .network__scroll::after { content: ""; flex: none; width: var(--board-w); height: 1px; } }
+
+.zone rect { fill: var(--ink); }
+.zone--even > rect { fill: var(--ink-zone); }
+.grid-dot { fill: #ffffff10; }
+.zone__tag rect { fill: none; stroke: var(--text); stroke-width: 2; }
+.zone__num { fill: var(--text); font-size: 17px; font-weight: 700; text-anchor: middle; }
+.zone__label { fill: var(--muted); font-size: 12px; }
+.zone__saga { fill: var(--text); font-size: 15px; font-weight: 500; }
+
+.ruler__axis { stroke: var(--rule); stroke-width: 2; }
+.ruler__tick { stroke: var(--muted); stroke-width: 2; }
+.ruler__year { fill: var(--text); font-size: 13px; font-weight: 500; text-anchor: middle; }
+.ruler__year--repeat { fill: var(--muted); font-size: 11.5px; font-weight: 400; }
+.ruler__caption { fill: var(--muted); font-size: 12px; }
+.ruler__gap path { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 2 6; stroke-linecap: round; }
+.ruler__gap rect { fill: var(--ink); stroke: var(--muted); stroke-width: 1.5; }
+.ruler__gap text { fill: var(--text); font-size: 12.5px; font-weight: 500; text-anchor: middle; }
+
+.line path { fill: none; stroke-linecap: round; stroke-linejoin: round; transition: opacity .25s; }
+.line__casing { stroke: var(--ink); stroke-width: calc(var(--lw) * 1px + 6px); }
+.line__track { stroke: var(--c); stroke-width: calc(var(--lw) * 1px); }
+.line__hit { stroke: transparent; stroke-width: 22px; cursor: pointer; pointer-events: stroke; }
+.line:hover .line__track { filter: brightness(1.15); }
+.map.has-line .line:not(.is-on) .line__track { opacity: .14; }
+.map.has-line .line:not(.is-on) .line__casing { opacity: .4; }
+.map.has-line .line.is-on .line__track { filter: drop-shadow(0 0 6px color-mix(in srgb, var(--c) 70%, transparent)); }
+
+/* One orchestrated moment: the network draws itself in on load. */
+.map--intro .line__track { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.6s cubic-bezier(.6,.1,.2,1) forwards; }
+.map--intro .line:nth-child(2) .line__track { animation-delay: .08s; }
+.map--intro .line:nth-child(3) .line__track { animation-delay: .16s; }
+.map--intro .line:nth-child(4) .line__track { animation-delay: .24s; }
+.map--intro .line:nth-child(5) .line__track { animation-delay: .32s; }
+.map--intro .line:nth-child(6) .line__track { animation-delay: .4s; }
+.map--intro .line:nth-child(7) .line__track { animation-delay: .48s; }
+.map--intro .station { animation: pop .4s ease-out both; animation-delay: 1.2s; }
+@keyframes draw { to { stroke-dashoffset: 0; } }
+@keyframes pop { from { opacity: 0; } }
+
+.station { cursor: pointer; outline: none; }
+.station__hit { fill: transparent; }
+.station__leader { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 1 4; stroke-linecap: round; opacity: .55; }
+.station .stop { transition: stroke .15s, fill .15s; }
+.station__name { fill: var(--text); font-size: 15px; font-weight: 500; transition: fill .2s, opacity .2s; }
+.station__num { fill: var(--muted); font-size: 12px; font-weight: 700; }
+.station:hover .stop, .station:focus-visible .stop { stroke: #ffd977; }
+.station:hover .station__name, .station:focus-visible .station__name { fill: #fff; font-weight: 700; }
+.station:focus-visible .stop { stroke-width: 5; }
+.station.is-open .stop { fill: #ffd977; }
+.station.is-open .station__name { fill: #ffd977; font-weight: 700; }
+.map.has-line .station:not(.is-served) .station__name { opacity: .3; }
+.map.has-line .station:not(.is-served) .stop { fill: #6f8394; }
+.map.has-line .station.is-served .station__leader { opacity: 1; stroke: var(--text); }
+
+.train rect { fill: var(--stop); stroke: var(--ink); stroke-width: 2.5; }
+.train circle { fill: #ffd977; }
+
+.sign {
+ position: absolute; z-index: 5; transform: translateX(-50%); pointer-events: none;
+ background: var(--enamel); color: var(--enamel-ink); border-radius: 4px; padding: 8px 12px 9px;
+ display: grid; gap: 1px; min-width: 180px; max-width: 260px; font-size: 13px;
+ box-shadow: 0 10px 30px -8px #00000099;
+}
+.sign strong { font-size: 14.5px; }
+.sign span { color: #4a5f70; }
+.sign__lines { display: flex; align-items: center; gap: 4px; margin-top: 4px; font-size: 12px; }
+.sign__lines i { width: 16px; height: 6px; border-radius: 3px; display: inline-block; }
+.sign__lines i:last-of-type { margin-right: 6px; }
+
+/* ---------- station board ---------- */
+.board {
+ position: fixed; top: 0; right: 0; bottom: var(--footer-h, 42px); width: var(--board-w); z-index: 10;
+ background: var(--enamel); color: var(--enamel-ink);
+ overflow-y: auto; transform: translateX(102%); visibility: hidden;
+ transition: transform .35s cubic-bezier(.2,.8,.2,1), visibility 0s .35s;
+ box-shadow: -24px 0 60px -20px #000000aa;
+ padding-bottom: 32px;
+}
+.board-open .board { transform: none; visibility: visible; transition: transform .35s cubic-bezier(.2,.8,.2,1); }
+.board__strip { display: flex; height: 12px; position: sticky; top: 0; z-index: 1; }
+.board__strip span { flex: 1; }
+.board__head { background: var(--enamel-ink); color: var(--enamel); padding: 16px 28px 22px; }
+.board__nav { display: flex; align-items: center; gap: 6px; margin-bottom: 14px; }
+.board__no { font-size: 13px; font-weight: 700; padding: 0 6px; }
+.board__step {
+ width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #ffffff55; background: none;
+ font-size: 18px; line-height: 1; display: grid; place-items: center; padding: 0 0 2px;
+}
+.board__step:disabled { opacity: .3; cursor: default; }
+.board__step:not(:disabled):hover { border-color: #fff; }
+.board__close { margin-left: auto; background: none; border: 1.5px solid #ffffff55; border-radius: 14px; padding: 2px 12px; font-size: 13px; }
+.board__close:hover { border-color: #fff; }
+.board__title { margin: 0; font-size: 34px; line-height: 1.05; font-weight: 700; letter-spacing: -0.02em; text-wrap: balance; }
+.board__zone { margin: 10px 0 0; display: flex; align-items: center; gap: 10px; font-size: 14px; color: #c6d2da; }
+.board__zonebox { width: 24px; height: 24px; border: 2px solid currentColor; border-radius: 3px; display: grid; place-items: center; font-weight: 700; font-size: 13px; color: var(--enamel); }
+
+.board__facts { margin: 0; padding: 18px 28px; display: grid; gap: 12px; border-bottom: 1px solid var(--enamel-rule); }
+.board__facts > div { display: grid; grid-template-columns: 92px 1fr; gap: 12px; }
+.board__facts dt { font-size: 12.5px; color: #5b6f7e; padding-top: 2px; }
+.board__facts dd { margin: 0; display: grid; }
+.board__facts dd strong { font-size: 15px; }
+.board__facts dd span { font-size: 13.5px; color: #4a5f70; }
+.board__homer { font-family: var(--serif); font-style: italic; font-weight: 500; font-size: 19px; line-height: 1.2; color: var(--enamel-ink); text-decoration-color: #9fb0bb; text-underline-offset: 3px; margin: 0; }
+.board__homer:hover { text-decoration-color: currentColor; }
+.board__homer--none { color: #5b6f7e; }
+
+.board__sec { padding: 18px 28px 4px; }
+.board__sec h3 { margin: 0 0 10px; font-size: 13px; font-weight: 700; color: #5b6f7e; }
+.board__fact p { margin: 0; font-size: 15px; max-width: 34em; }
+
+.chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.chip {
+ display: inline-flex; align-items: center; gap: 8px; padding: 4px 11px 4px 8px; border-radius: 15px;
+ background: #fff; border: 1.5px solid var(--enamel-rule); font-size: 13px; font-weight: 500; color: var(--enamel-ink);
+}
+.chip__bar { width: 18px; height: 7px; border-radius: 4px; background: var(--c); box-shadow: inset 0 0 0 1px #00000026; }
+.chip:hover { border-color: var(--enamel-ink); }
+.chip.is-on { background: var(--enamel-ink); color: var(--enamel); border-color: var(--enamel-ink); }
+.chips--small .chip { font-size: 12px; padding: 2px 9px 2px 7px; }
+
+.platforms { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.platform {
+ display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 12px 12px 12px 10px;
+ background: #fff; border-radius: 6px; border: 1.5px solid var(--enamel-rule); transition: opacity .2s, border-color .2s;
+}
+.platform__letter {
+ width: 32px; height: 32px; border-radius: 50%; background: var(--enamel-ink); color: var(--enamel);
+ display: grid; place-items: center; font-weight: 700; font-size: 15px;
+}
+.platform h4 { margin: 4px 0 6px; font-size: 15px; }
+.platform h4 span { font-weight: 400; color: #5b6f7e; font-size: 13.5px; margin-left: 4px; }
+.platform blockquote { margin: 0 0 10px; font-family: var(--serif); font-style: italic; font-weight: 500; font-size: 17px; line-height: 1.35; color: #2b4152; }
+.platform blockquote p { margin: 0; }
+.board.has-line .platform:not(.is-served) { opacity: .45; }
+.board.has-line .platform.is-served { border-color: var(--enamel-ink); box-shadow: inset 4px 0 0 var(--enamel-ink); }
+
+.board .video-cards { gap: 6px; }
+.board .video-card {
+ border: 1.5px solid var(--enamel-rule); background: #fff; border-radius: 6px; padding: 9px 12px; font-size: 14px; font-weight: 500;
+ display: flex; align-items: center; gap: 10px;
+}
+.board .video-card::before {
+ content: ""; width: 0; height: 0; border-style: solid; border-width: 6px 0 6px 10px;
+ border-color: transparent transparent transparent #c0392b; flex: none;
+}
+.board .video-card:hover { border-color: var(--enamel-ink); opacity: 1; }
+.board .audio-slot { border-radius: 6px; border-color: #9fb0bb; font-size: 14px; color: #4a5f70; }
+
+/* ---------- credits ---------- */
+/* Credits stay fully legible in every state: opaque, full opacity, never covered by the board. */
+body .fan-credits { position: relative; z-index: 11; opacity: 1; background: var(--ink-deep); border-top: 1px solid var(--rule); color: #b4c4d0; font-size: 12.5px; padding: 10px 24px; }
+
+/* ---------- small screens ---------- */
+@media (max-width: 900px) {
+ body { overflow: auto; height: auto; }
+ #app { grid-template-columns: 1fr; }
+ .key { border-right: 0; border-bottom: 1px solid var(--rule); }
+ .network { height: 70vh; }
+ .board { width: 100%; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation: none !important; transition: none !important; }
+}