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:
1 parent
96cb4b536a
commit
7da49cca31
3 files changed
+830
-4
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user