From 7da49cca31f0e4b8bb8cba9eabf26ad53763273d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 23 Oct 2026 18:40:02 +0200 Subject: [PATCH] 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 --- src/mockups/02-vertical-story/index.html | 3 +- src/mockups/02-vertical-story/main.ts | 359 ++++++++++++++++- src/mockups/02-vertical-story/style.css | 472 ++++++++++++++++++++++- 3 files changed, 830 insertions(+), 4 deletions(-) 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; } +}