feat(mockups): 02 vertical story

Dark cinematic vertical voyage: songs surface as the ship scrolls past,
sticky detail panel, motif mini-map, gaps tracking in-story years.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-23 18:40:02 +02:00
1 parent 96cb4b536a
commit 7da49cca31
3 files changed
+830 -4

No files matched your search

+2 -1
View File
@@ -7,7 +7,8 @@
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<body data-saga="troy">
<div class="sea" aria-hidden="true"></div>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
+358 -1
View File
@@ -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 = `<h1>Vertical story</h1><p>stub</p>`;
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<K extends keyof HTMLElementTagNameMap>(
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 = `
<p class="opening__kicker">EPIC: The Musical, told as a voyage</p>
<h1 class="opening__title">From Troy,<br /><span>the long way home</span></h1>
<p class="opening__lede">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.</p>
<p class="opening__cue" aria-hidden="true"><span></span></p>`;
/* ---------- 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 = `<p class="coda__text">The sample voyage ends here.<br />Ithaca is still over the horizon.</p>`;
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<HTMLElement>(".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<string>();
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<HTMLElement>(".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<HTMLElement>(".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));
+470 -2
View File
@@ -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: "<color>"; 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; }
}