feat(mockups): 01 editorial scroll

Parchment-and-serif horizontal timeline, motif threads beneath,
gap length tracking in-story time, folio song detail.

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:38:24 +02:00
1 parent 80f6c97127
commit 24f9ecaaad
2 files changed
+1014 -3

No files matched your search

+526 -1
View File
@@ -1,5 +1,530 @@
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();
/* ------------------------------------------------------------------ geometry
Every song is one STEP from the last. On top of that, each year that passes
in the story adds YEAR pixels, so elapsed story time reads as length. */
const STEP = 210;
const YEAR = 560;
const START_X = 400;
const END_PAD = 520;
const LINE_Y = 200;
const RULER_Y = 52;
const SAGA_Y = 14;
const ROW = 28;
const MOTIF_TOP = 304;
const CHAR_TOP = 444;
const SVG_NS = "http://www.w3.org/2000/svg";
// The shared motif colours are tuned for dark pages; on parchment they are
// re-inked to darker equivalents of the same hue so every thread stays legible.
const INK: Record<string, string> = {
warrior: "#a8740f",
"open-arms": "#c0573b",
"just-a-man": "#3c6a96",
remember: "#4c7f45",
odysseus: "#6b4a2b",
athena: "#7354a3",
polyphemus: "#9e2b2b",
};
const ink = (m: Motif) => INK[m.id] ?? m.color;
const xs: number[] = [];
songs.forEach((s, i) => {
if (i === 0) xs.push(START_X);
else xs.push(xs[i - 1]! + STEP + Math.max(0, gapYears(songs[i - 1]!, s)) * YEAR);
});
const WIDTH = xs[xs.length - 1]! + END_PAD;
const HEIGHT = CHAR_TOP + 3 * ROW + 10;
const motifGroups = {
motif: motifs.filter((m) => m.kind === "motif"),
character: motifs.filter((m) => m.kind === "character"),
};
const threadY = new Map<string, number>();
motifGroups.motif.forEach((m, i) => threadY.set(m.id, MOTIF_TOP + i * ROW));
motifGroups.character.forEach((m, i) => threadY.set(m.id, CHAR_TOP + i * ROW));
/* ------------------------------------------------------------------ helpers */
type Attrs = Record<string, string | number | undefined>;
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
el.append(...kids);
return el;
}
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
return el;
}
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV"];
const WORDS = ["no time", "a year", "two years", "three years", "four years", "five years"];
const yearsPass = (n: number) => (n === 1 ? "a year passes" : `${WORDS[n] ?? `${n} years`} pass`);
const andList = (xs: string[]) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Editorial scroll</h1><p>stub</p>`;
const masthead = h(
"header",
{ class: "masthead" },
h("h1", { class: "masthead__title" }, "EPIC: The Musical, ", h("em", {}, "measured in years")),
h(
"p",
{ class: "masthead__dek" },
"Every song sits on one line in story order. The space between two songs grows with the years Odysseus loses between them. The threads underneath carry the recurring motifs.",
),
);
const scroller = h("section", { class: "voyage", tabindex: 0, "aria-label": "Timeline of songs. Scroll or drag sideways to travel." });
const track = h("div", { class: "voyage__track", style: `width:${WIDTH}px;height:${HEIGHT}px` });
const svg = s("svg", { class: "voyage__svg", width: WIDTH, height: HEIGHT, viewBox: `0 0 ${WIDTH} ${HEIGHT}`, "aria-hidden": "true" });
track.append(svg);
scroller.append(track);
const margin = h("nav", { class: "margin", "aria-label": "Motifs" });
const caption = h("p", { class: "caption", "aria-live": "polite" });
const progress = h("div", { class: "progress", "aria-hidden": "true" }, h("span", { class: "progress__bar" }));
const stage = h("div", { class: "stage" }, scroller, margin);
const below = h("div", { class: "below" }, caption, progress);
const folio = h("aside", { class: "folio", "aria-label": "Song notes", "aria-hidden": "true" });
app.append(masthead, stage, below, folio);
/* ------------------------------------------------------------------ svg: sagas, ruler, line */
const gSaga = s("g", { class: "sagas" });
for (const saga of sagas) {
const idx = songs.map((so, i) => (so.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const a = xs[idx[0]!]! - 70;
const b = xs[idx[idx.length - 1]!]! + 70;
gSaga.append(s("path", { d: `M${a} ${SAGA_Y + 26} v-6 H${b} v6`, class: "saga-bracket" }));
const label = h("div", { class: "saga-head", style: `left:${a}px;top:${SAGA_Y - 6}px;width:${b - a}px` }, saga.name);
track.append(label);
}
svg.append(gSaga);
// Ruler: years after the fall of Troy, marked where each new year begins.
const gRuler = s("g", { class: "ruler" });
gRuler.append(s("line", { x1: START_X - 140, x2: WIDTH - 60, y1: RULER_Y, y2: RULER_Y }));
songs.forEach((so, i) => {
const gy = i === 0 ? 1 : gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
// Sits where the elapsed stretch (and its month ticks) ends: half a STEP before the song.
const x = xs[i]! - STEP / 2;
gRuler.append(s("line", { x1: x, x2: x, y1: RULER_Y - 7, y2: RULER_Y + 7, class: "ruler__year" }));
const y = so.storyTime.yearsAfterTroy;
track.append(
h("div", { class: "ruler-label", style: `left:${x + 7}px;top:${RULER_Y + 2}px` }, y === 0 ? "The year Troy falls" : `Year ${y} after Troy`),
);
});
svg.append(gRuler);
const gLine = s("g", { class: "line" });
gLine.append(s("line", { x1: START_X - 140, x2: WIDTH - 60, y1: LINE_Y, y2: LINE_Y, class: "line__main" }));
// Elapsed story time: month ticks hang off the line through every gap.
songs.forEach((so, i) => {
if (i === 0) return;
const gy = gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const a = xs[i - 1]! + STEP / 2;
const months = gy * 12;
for (let m = 0; m <= months; m++) {
const x = a + (m / 12) * YEAR;
const big = m % 12 === 0;
gLine.append(s("line", { x1: x, x2: x, y1: LINE_Y, y2: LINE_Y + (big ? 12 : m % 3 === 0 ? 7 : 4), class: big ? "tick tick--year" : "tick" }));
}
track.append(
h("div", { class: "elapsed", style: `left:${a}px;width:${gy * YEAR}px;top:${LINE_Y - 40}px` }, yearsPass(gy)),
);
});
svg.append(gLine);
/* ------------------------------------------------------------------ svg: motif threads */
const gThreads = s("g", { class: "threads" });
const threadEls = new Map<string, SVGGElement>();
for (const m of motifs) {
const y = threadY.get(m.id)!;
const appear = songs.map((so, i) => (so.parts.some((p) => p.motifIds.includes(m.id)) ? i : -1)).filter((i) => i >= 0);
const g = s("g", { class: "thread", "data-motif": m.id, style: `--ink:${ink(m)}` });
g.append(s("line", { x1: START_X - 140, x2: WIDTH - 60, y1: y, y2: y, class: "thread__ghost" }));
if (appear.length) {
// The thread is pinned at each song that uses the motif and sags between pins.
let d = `M${xs[appear[0]!]} ${y}`;
for (let k = 1; k < appear.length; k++) {
const x0 = xs[appear[k - 1]!]!;
const x1 = xs[appear[k]!]!;
const sag = Math.min(11, 2 + (x1 - x0) / 120);
d += ` Q${(x0 + x1) / 2} ${y + sag * 2} ${x1} ${y}`;
}
g.append(s("path", { d, class: "thread__live" }));
for (const i of appear) {
const x = xs[i]!;
g.append(s("line", { x1: x, x2: x, y1: LINE_Y + 10, y2: y - 6, class: "thread__leader", "data-song": songs[i]!.id }));
g.append(s("circle", { cx: x, cy: y, r: 4.2, class: "thread__knot", "data-song": songs[i]!.id }));
}
}
threadEls.set(m.id, g);
gThreads.append(g);
}
svg.append(gThreads);
/* ------------------------------------------------------------------ song nodes */
const songBtns = new Map<string, HTMLButtonElement>();
songs.forEach((so, i) => {
const btn = h(
"button",
{ type: "button", class: "song", "data-song": so.id, style: `left:${xs[i]}px;top:${LINE_Y - 132}px`, "aria-label": `${so.title}, track ${so.track}. Open notes.` },
h("span", { class: "song__head" }, h("span", { class: "song__num" }, String(so.track)), h("span", { class: "song__title" }, so.title)),
h("span", { class: "song__dot" }),
h("span", { class: "song__when" }, so.storyTime.label),
) as HTMLButtonElement;
btn.addEventListener("click", () => {
if (dragMoved) return;
openSong(so.id);
});
btn.addEventListener("mouseenter", () => setHoverSong(so.id));
btn.addEventListener("mouseleave", () => setHoverSong(null));
btn.addEventListener("focus", () => {
setHoverSong(so.id);
if (!folioOpen) centreOn(i);
});
btn.addEventListener("blur", () => setHoverSong(null));
songBtns.set(so.id, btn);
track.append(btn);
});
/* ------------------------------------------------------------------ margin: thread labels */
const motifBtns = new Map<string, HTMLButtonElement>();
function marginGroup(title: string, list: Motif[], top: number) {
margin.append(h("p", { class: "margin__group", style: `top:${top - 30}px` }, title));
for (const m of list) {
const count = songsWithMotif(m.id).length;
const b = h(
"button",
{ type: "button", class: "motif", "data-motif": m.id, style: `top:${threadY.get(m.id)! - 12}px;--ink:${ink(m)}`, "aria-pressed": "false" },
h("span", { class: "motif__swatch" }),
h("span", { class: "motif__name" }, m.name),
h("span", { class: "motif__count" }, String(count)),
) as HTMLButtonElement;
b.title = m.description;
b.addEventListener("click", () => selectMotif(selectedMotif === m.id ? null : m.id));
motifBtns.set(m.id, b);
margin.append(b);
}
}
marginGroup("Recurring motifs", motifGroups.motif, MOTIF_TOP);
marginGroup("Character themes", motifGroups.character, CHAR_TOP);
/* ------------------------------------------------------------------ state */
let selectedMotif: string | null = null;
let openId: string | null = null;
let folioOpen = false;
const DEFAULT_CAPTION =
"Scroll or drag sideways to travel. Select a song to open its notes, or a thread in the margin to follow a motif through the story.";
function setHoverSong(id: string | null) {
svg.querySelectorAll(".is-hover").forEach((e) => e.classList.remove("is-hover"));
if (!id) return;
svg.querySelectorAll(`[data-song="${id}"]`).forEach((e) => e.classList.add("is-hover"));
}
function selectMotif(id: string | null) {
selectedMotif = id;
document.body.classList.toggle("has-motif", !!id);
const hits = new Set(id ? songsWithMotif(id).map((x) => x.id) : []);
for (const [mid, g] of threadEls) g.classList.toggle("is-selected", mid === id);
for (const [mid, b] of motifBtns) {
b.classList.toggle("is-selected", mid === id);
b.setAttribute("aria-pressed", String(mid === id));
}
const m = motifs.find((x) => x.id === id);
for (const [sid, b] of songBtns) {
b.classList.toggle("is-motif", hits.has(sid));
b.style.setProperty("--motif-ink", m ? ink(m) : "");
}
document.body.style.setProperty("--motif-ink", m ? ink(m) : "");
if (m) {
const n = hits.size;
caption.replaceChildren(
h("em", { class: "caption__name", style: `color:${ink(m)}` }, m.name),
` is heard in ${n} of ${songs.length} songs. ${m.description} `,
h("button", { type: "button", class: "caption__clear" }, "Stop following"),
);
caption.querySelector("button")!.addEventListener("click", () => selectMotif(null));
} else caption.textContent = DEFAULT_CAPTION;
if (openId) markParts();
// Bring the motif into view if none of its songs are on screen.
if (id && !folioOpen) {
const idx = songs.map((x, i) => (hits.has(x.id) ? i : -1)).filter((i) => i >= 0);
const left = scroller.scrollLeft + margin.getBoundingClientRect().width;
const right = scroller.scrollLeft + scroller.clientWidth - 80;
if (idx.length && !idx.some((i) => xs[i]! > left && xs[i]! < right)) centreOn(idx[0]!);
}
}
function markParts() {
folio.querySelectorAll<HTMLElement>(".part").forEach((p) => {
const ids = (p.dataset.motifs ?? "").split(" ");
p.classList.toggle("is-motif", !!selectedMotif && ids.includes(selectedMotif));
});
folio.querySelectorAll<HTMLElement>(".chip").forEach((c) => {
c.classList.toggle("is-selected", c.dataset.motif === selectedMotif);
});
}
/* ------------------------------------------------------------------ folio (song detail) */
function chip(m: Motif) {
const b = h("button", { type: "button", class: "chip", "data-motif": m.id, style: `--ink:${ink(m)}` }, h("span", { class: "chip__thread" }), m.name);
b.addEventListener("click", () => selectMotif(selectedMotif === m.id ? null : m.id));
return b;
}
function renderFolio(song: Song) {
const i = songs.indexOf(song);
const saga = sagaOf(song);
const prev = songs[i - 1];
const next = songs[i + 1];
const y = song.storyTime.yearsAfterTroy;
const close = h("button", { type: "button", class: "folio__close" }, "Close");
close.addEventListener("click", closeSong);
const nav = h("div", { class: "folio__nav" });
for (const [label, target] of [["Previous song", prev], ["Next song", next]] as const) {
const b = h("button", { type: "button", class: "folio__step" }, label) as HTMLButtonElement;
if (target) {
b.title = target.title;
b.addEventListener("click", () => openSong(target.id));
} else b.disabled = true;
nav.append(b);
}
const homer = song.odyssey
? h(
"dd",
{},
`Book ${ROMAN[song.odyssey.book] ?? song.odyssey.book}, lines ${song.odyssey.lines.replace("-", "–")}. `,
h("a", { href: song.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, "Read it in Butler’s translation"),
)
: h("dd", { class: "is-muted" }, "No counterpart in Homer; this scene is the musical’s own.");
const parts = h("ol", { class: "parts" });
for (const p of song.parts) {
const ms = p.motifIds.flatMap((id) => motifs.find((m) => m.id === id) ?? []);
parts.append(
h(
"li",
{ class: "part", "data-motifs": p.motifIds.join(" "), style: `--rule:${ms[0] ? ink(ms[0]) : "currentColor"}` },
h("p", { class: "part__head" }, h("span", { class: "part__name" }, p.name), h("span", { class: "part__singers" }, andList(p.singers))),
h("div", { class: "part__lines" }, ...p.lines.map((l) => h("p", {}, l))),
h("div", { class: "part__motifs" }, ...ms.map(chip)),
),
);
}
const facts = h("ol", { class: "facts" }, ...song.facts.map((f) => h("li", {}, f)));
folio.replaceChildren(
h(
"div",
{ class: "folio__bar" },
h("p", { class: "folio__where" }, `${saga.name}, track ${song.track} of ${songs.length}`),
nav,
close,
),
h(
"article",
{ class: "folio__body" },
h("h2", { class: "folio__title", tabindex: -1 }, song.title),
h("p", { class: "folio__when" }, song.storyTime.label, h("span", {}, y === 0 ? "the year Troy falls" : `year ${y} after Troy`)),
h(
"dl",
{ class: "folio__meta" },
h("dt", {}, "In the Odyssey"),
homer,
h("dt", {}, "Motifs"),
h("dd", { class: "folio__chips" }, ...motifsInSong(song).map(chip)),
h("dt", {}, "Listen"),
h("dd", {}, createAudioSlot(song)),
h("dt", {}, "Watch"),
h("dd", {}, createVideoCards(song)),
),
h("h3", { class: "folio__section" }, "The song, part by part"),
parts,
h("aside", { class: "folio__fact" }, h("h3", { class: "folio__section" }, "A note"), facts),
),
);
folio.scrollTop = 0;
markParts();
}
function openSong(id: string) {
const song = songs.find((x) => x.id === id);
if (!song) return;
const already = folioOpen;
openId = id;
for (const [sid, b] of songBtns) b.classList.toggle("is-open", sid === id);
const swap = () => {
renderFolio(song);
// Move focus into the folio so keyboard and screen-reader users land on the new song.
requestAnimationFrame(() => folio.querySelector<HTMLElement>(".folio__title")?.focus({ preventScroll: true }));
};
if (already && !reduceMotion.matches) {
folio.classList.add("is-turning");
setTimeout(() => {
swap();
folio.classList.remove("is-turning");
}, 180);
} else swap();
folioOpen = true;
document.body.classList.add("folio-open");
// Leave room past the last song so even it can sit beside the open folio.
if (innerWidth > 760) track.style.width = `${WIDTH + folio.getBoundingClientRect().width}px`;
folio.setAttribute("aria-hidden", "false");
centreOn(songs.indexOf(song));
}
function closeSong() {
const last = openId;
openId = null;
folioOpen = false;
document.body.classList.remove("folio-open");
folio.setAttribute("aria-hidden", "true");
track.style.width = `${WIDTH}px`;
onScroll();
for (const b of songBtns.values()) b.classList.remove("is-open");
if (last) songBtns.get(last)?.focus({ preventScroll: true });
}
window.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape" && folioOpen) closeSong();
});
/* ------------------------------------------------------------------ panning */
let target = 0;
let raf = 0;
const maxScroll = () => scroller.scrollWidth - scroller.clientWidth;
function glideTo(x: number, instant = false) {
target = Math.max(0, Math.min(maxScroll(), x));
if (instant || reduceMotion.matches) {
cancelAnimationFrame(raf);
raf = 0;
scroller.scrollLeft = target;
return;
}
if (!raf) raf = requestAnimationFrame(step);
}
function step() {
const cur = scroller.scrollLeft;
const d = target - cur;
if (Math.abs(d) < 0.6) {
scroller.scrollLeft = target;
raf = 0;
return;
}
scroller.scrollLeft = cur + d * 0.14;
raf = requestAnimationFrame(step);
}
function centreOn(i: number, instant = false) {
const folioW = folioOpen || document.body.classList.contains("folio-open") ? folio.getBoundingClientRect().width : 0;
const visible = scroller.clientWidth - (innerWidth > 760 ? folioW : 0);
const marginW = margin.getBoundingClientRect().width;
glideTo(xs[i]! - (visible + marginW) / 2, instant);
}
// A vertical wheel travels sideways; horizontal trackpad gestures pass straight through.
scroller.addEventListener(
"wheel",
(e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
target = scroller.scrollLeft;
return;
}
e.preventDefault();
const unit = e.deltaMode === 1 ? 32 : e.deltaMode === 2 ? scroller.clientWidth : 1;
glideTo((raf ? target : scroller.scrollLeft) + e.deltaY * unit * 1.2);
},
{ passive: false },
);
// Drag to pan with a mouse; touch uses native scrolling.
let dragMoved = false;
scroller.addEventListener("pointerdown", (e) => {
if (e.pointerType !== "mouse" || e.button !== 0) return;
const startX = e.clientX;
const startLeft = scroller.scrollLeft;
let last = e.clientX;
let lastT = performance.now();
let v = 0;
dragMoved = false;
cancelAnimationFrame(raf);
raf = 0;
const move = (ev: PointerEvent) => {
const dx = ev.clientX - startX;
if (!dragMoved && Math.abs(dx) > 4) {
dragMoved = true;
scroller.classList.add("is-dragging");
scroller.setPointerCapture(e.pointerId);
}
if (!dragMoved) return;
const now = performance.now();
v = (ev.clientX - last) / Math.max(1, now - lastT);
last = ev.clientX;
lastT = now;
scroller.scrollLeft = startLeft - dx;
};
const up = () => {
scroller.removeEventListener("pointermove", move);
scroller.removeEventListener("pointerup", up);
scroller.removeEventListener("pointercancel", up);
scroller.classList.remove("is-dragging");
if (dragMoved) glideTo(scroller.scrollLeft - v * 260);
setTimeout(() => (dragMoved = false), 0);
};
scroller.addEventListener("pointermove", move);
scroller.addEventListener("pointerup", up);
scroller.addEventListener("pointercancel", up);
});
const bar = progress.firstElementChild as HTMLElement;
function onScroll() {
const max = maxScroll();
const frac = scroller.clientWidth / scroller.scrollWidth;
bar.style.width = `${frac * 100}%`;
bar.style.transform = `translateX(${max > 0 ? (scroller.scrollLeft / max) * ((1 - frac) / frac) * 100 : 0}%)`;
scroller.classList.toggle("at-start", scroller.scrollLeft < 8);
scroller.classList.toggle("at-end", scroller.scrollLeft > max - 8);
}
scroller.addEventListener("scroll", onScroll, { passive: true });
addEventListener("resize", onScroll);
/* ------------------------------------------------------------------ boot */
selectMotif(null);
onScroll();
// ?song=<id> and ?motif=<id> open a state on load (used for screenshots).
const params = new URLSearchParams(location.search);
if (params.has("song") || params.has("motif")) {
document.body.classList.add("no-anim");
setTimeout(() => document.body.classList.remove("no-anim"), 800);
}
const qMotif = params.get("motif");
if (qMotif && motifs.some((m) => m.id === qMotif)) {
selectMotif(qMotif);
if (!params.has("song")) centreOn(songs.findIndex((x) => songsWithMotif(qMotif).includes(x)), true);
}
const qSong = params.get("song");
if (qSong && songs.some((x) => x.id === qSong)) {
openSong(qSong);
requestAnimationFrame(() => centreOn(songs.findIndex((x) => x.id === qSong), true));
}
requestAnimationFrame(() => document.body.classList.add("is-ready"));
+488 -2
View File
@@ -1,2 +1,488 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "01 · "; }
/* 01 · Editorial scroll — parchment, sepia ink, one long line. */
@import "@fontsource/cormorant-garamond/latin-400.css";
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
:root {
color-scheme: light;
--parchment: #e8dbbf;
--parchment-deep: #dccaa6;
--leaf: #f2e8d2;
--ink: #2c2117;
--ink-soft: #6c5b45;
--ink-faint: #a8977a;
--rule: #b9a580;
--wine: #5e1a2a;
--serif: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--ease: cubic-bezier(0.2, 0.75, 0.15, 1);
--folio-w: min(43rem, 100vw);
}
* { box-sizing: border-box; }
html { background: var(--parchment); }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--ink);
font-family: var(--serif);
font-size: 19px;
line-height: 1.45;
font-variant-numeric: oldstyle-nums proportional-nums;
font-kerning: normal;
background-color: var(--parchment);
background-image:
radial-gradient(120% 90% at 50% 40%, transparent 55%, rgb(120 88 40 / 0.16) 100%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .35 0 0 0 0 .26 0 0 0 0 .14 0 0 0 .09 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
overflow-x: hidden;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--wine); outline-offset: 3px; border-radius: 2px; }
#app { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* ---------------------------------------------------------------- masthead */
.masthead {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 30rem);
align-items: end;
gap: 1rem 4rem;
padding: 2.4rem 3rem 1.3rem;
border-bottom: 1px solid var(--rule);
margin: 0 3rem;
padding-inline: 0;
}
.masthead__title {
margin: 0;
font-weight: 500;
font-size: clamp(2.2rem, 4.2vw, 3.6rem);
line-height: 0.98;
letter-spacing: -0.012em;
}
.masthead__title em { display: block; font-weight: 400; color: var(--ink-soft); }
.masthead__dek {
margin: 0;
font-size: 1.04rem;
line-height: 1.5;
color: var(--ink-soft);
font-style: italic;
max-width: 30rem;
justify-self: end;
}
/* ---------------------------------------------------------------- the voyage */
.stage { position: relative; margin-top: 1.4rem; }
.voyage {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
cursor: grab;
overscroll-behavior-x: contain;
/* the left margin is where threads slip under their labels; the right edge fades onward */
--fade-l: linear-gradient(90deg, transparent 0, transparent 13.5rem, #000 16rem);
-webkit-mask-image: var(--fade-l), linear-gradient(90deg, #000 calc(100% - 7rem), transparent);
mask-image: var(--fade-l), linear-gradient(90deg, #000 calc(100% - 7rem), transparent);
-webkit-mask-composite: source-in;
mask-composite: intersect;
}
.voyage::-webkit-scrollbar { display: none; }
.voyage.at-end { -webkit-mask-image: var(--fade-l); mask-image: var(--fade-l); }
.voyage.is-dragging { cursor: grabbing; user-select: none; }
.voyage:focus-visible { outline-offset: -3px; }
.voyage__track { position: relative; }
.voyage__svg { position: absolute; inset: 0; overflow: visible; }
/* sagas and ruler */
.saga-bracket { fill: none; stroke: var(--ink-soft); stroke-width: 0.8; }
.saga-head {
position: absolute;
font-style: italic;
font-size: 1.12rem;
color: var(--ink-soft);
text-align: center;
white-space: nowrap;
pointer-events: none;
}
.ruler line { stroke: var(--rule); stroke-width: 0.8; }
.ruler .ruler__year { stroke: var(--ink-soft); stroke-width: 1; }
.ruler-label {
position: absolute;
font-size: 0.95rem;
font-style: italic;
color: var(--ink);
white-space: nowrap;
pointer-events: none;
}
/* the line */
.line__main { stroke: var(--ink); stroke-width: 1.4; }
.tick { stroke: var(--ink-soft); stroke-width: 0.7; }
.tick--year { stroke: var(--ink); stroke-width: 1; }
.elapsed {
position: absolute;
text-align: center;
font-style: italic;
font-size: 1.05rem;
color: var(--ink-soft);
pointer-events: none;
white-space: nowrap;
}
.elapsed::before,
.elapsed::after {
content: "";
display: inline-block;
vertical-align: middle;
width: 3.5rem;
height: 1px;
margin: 0 0.8rem;
background: var(--rule);
}
/* songs */
.song {
position: absolute;
width: 11.5rem;
height: 204px;
margin-left: -5.75rem;
padding: 0;
border: 0;
background: none;
cursor: pointer;
display: grid;
grid-template-rows: 120px 24px 60px;
justify-items: center;
text-align: center;
}
.song__head {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 0.15rem;
padding-bottom: 0.7rem;
transition: transform 0.35s var(--ease);
}
.song__num { font-size: 0.95rem; font-style: italic; color: var(--ink-faint); }
.song__title {
font-size: 1.42rem;
font-weight: 500;
line-height: 1.04;
text-wrap: balance;
transition: color 0.25s;
}
.song__dot {
grid-row: 2;
align-self: center;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--parchment);
border: 1.5px solid var(--ink);
transition: transform 0.3s var(--ease), background-color 0.25s, border-color 0.25s;
}
.song__when {
grid-row: 3;
margin-top: 0.55rem;
font-size: 0.92rem;
font-style: italic;
line-height: 1.2;
color: var(--ink-soft);
max-width: 9.5rem;
text-wrap: balance;
}
.song:hover .song__title,
.song:focus-visible .song__title { color: var(--wine); }
.song:hover .song__head,
.song:focus-visible .song__head { transform: translateY(-4px); }
.song:hover .song__dot,
.song:focus-visible .song__dot { transform: scale(1.45); background: var(--ink); }
.song:focus-visible { outline: none; }
.song:focus-visible .song__title { text-decoration: underline 1px; text-underline-offset: 4px; }
.song.is-open .song__dot { background: var(--wine); border-color: var(--wine); transform: scale(1.7); box-shadow: 0 0 0 5px rgb(94 26 42 / 0.14); }
.song.is-open .song__title { color: var(--wine); }
/* motif following: dim what isn't part of it */
.song, .thread { transition: opacity 0.35s; }
.has-motif .song:not(.is-motif) { opacity: 0.38; }
.has-motif .song.is-motif .song__dot { background: var(--motif-ink); border-color: var(--motif-ink); transform: scale(1.35); }
.has-motif .song.is-motif .song__title { text-decoration: underline 1.5px var(--motif-ink); text-underline-offset: 5px; }
/* threads */
.thread__ghost { stroke: var(--ink); stroke-opacity: 0.1; stroke-width: 0.6; stroke-dasharray: 1 5; }
.thread__live { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-linecap: round; transition: stroke-width 0.3s; }
.thread__knot { fill: var(--ink); stroke: var(--parchment); stroke-width: 2; transition: r 0.3s var(--ease); }
.thread__leader { stroke: var(--ink); stroke-width: 0.8; stroke-dasharray: 2 3; opacity: 0; transition: opacity 0.3s; }
.thread__knot.is-hover { r: 6; }
.thread__leader.is-hover { opacity: 0.45; }
.has-motif .thread:not(.is-selected) { opacity: 0.22; }
.thread.is-selected .thread__live { stroke-width: 3; }
.thread.is-selected .thread__knot { r: 6.5; }
.thread.is-selected .thread__leader { opacity: 0.8; }
/* margin: thread labels sit over the left edge, threads slide beneath them */
.margin {
position: absolute;
inset: 0 auto 0 0;
width: 17.5rem;
pointer-events: none;
}
.margin > * { pointer-events: auto; }
.margin__group {
position: absolute;
left: 3rem;
margin: 0;
font-style: italic;
font-size: 0.98rem;
color: var(--ink-soft);
}
.motif {
position: absolute;
left: 2.4rem;
height: 24px;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0 0.6rem;
border: 0;
border-radius: 3px;
background: none;
cursor: pointer;
font-size: 1.04rem;
white-space: nowrap;
}
.motif__swatch { width: 1.6rem; height: 2px; background: var(--ink); transition: height 0.25s, width 0.3s var(--ease); }
.motif__name { transition: color 0.2s; }
.motif__count { font-size: 0.86rem; font-style: italic; color: var(--ink-faint); }
.motif:hover .motif__name { color: var(--wine); }
.motif:hover .motif__swatch { width: 2.2rem; }
.motif.is-selected .motif__swatch { height: 3px; width: 2.4rem; }
.motif.is-selected .motif__name { font-weight: 600; }
/* ---------------------------------------------------------------- caption */
.below {
display: grid;
grid-template-columns: minmax(0, 46rem) minmax(8rem, 14rem);
justify-content: space-between;
align-items: center;
gap: 2rem;
margin: 0.4rem 3rem 0;
padding-top: 0.9rem;
border-top: 1px solid var(--rule);
}
.caption { margin: 0; font-size: 1.02rem; color: var(--ink-soft); min-height: 3em; }
.caption__name { font-weight: 600; font-style: italic; }
.caption__clear {
border: 0;
padding: 0;
background: none;
cursor: pointer;
color: var(--wine);
text-decoration: underline 1px;
text-underline-offset: 3px;
}
.progress { height: 2px; background: var(--parchment-deep); overflow: hidden; }
.progress__bar { display: block; height: 100%; background: var(--ink-soft); }
/* ---------------------------------------------------------------- folio (song detail) */
.folio {
position: fixed;
inset: 0 0 0 auto;
width: var(--folio-w);
z-index: 10;
overflow-y: auto;
background: var(--leaf);
border-left: 1px solid var(--rule);
box-shadow: -24px 0 50px -30px rgb(60 40 15 / 0.55), -1px 0 0 rgb(255 255 255 / 0.4) inset;
transform: translateX(103%);
visibility: hidden;
transition: transform 0.55s var(--ease), visibility 0s linear 0.55s;
}
.folio-open .folio { transform: none; visibility: visible; transition: transform 0.55s var(--ease), visibility 0s; }
.folio > * { transition: opacity 0.18s, transform 0.18s; }
.folio.is-turning > * { opacity: 0; transform: translateX(10px); }
.folio__bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 2.6rem;
background: var(--leaf);
border-bottom: 1px solid var(--rule);
font-size: 0.98rem;
}
.folio__where { margin: 0 auto 0 0; font-style: italic; color: var(--ink-soft); }
.folio__nav { display: flex; gap: 0.2rem; }
.folio__step, .folio__close {
border: 0;
background: none;
padding: 0.25rem 0.55rem;
cursor: pointer;
border-radius: 3px;
}
.folio__step:hover:not(:disabled), .folio__close:hover { color: var(--wine); text-decoration: underline 1px; text-underline-offset: 3px; }
.folio__step:disabled { color: var(--ink-faint); cursor: default; }
.folio__close { font-weight: 600; }
.folio__body { padding: 2.2rem 2.6rem 3.5rem; max-width: 38rem; }
.folio__title {
margin: 0;
font-size: 3.4rem;
font-weight: 500;
line-height: 0.95;
letter-spacing: -0.015em;
}
.folio__title:focus { outline: none; }
.folio__title:focus-visible { text-decoration: underline 1px var(--ink-faint); text-underline-offset: 8px; }
.folio__when { margin: 0.7rem 0 0; font-size: 1.2rem; font-style: italic; }
.folio__when span { margin-left: 0.7rem; padding-left: 0.7rem; border-left: 1px solid var(--rule); color: var(--ink-soft); }
.folio__meta {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: 0.7rem 1.2rem;
margin: 1.8rem 0 0;
padding: 1.1rem 0;
border-block: 1px solid var(--rule);
}
.folio__meta dt { font-style: italic; color: var(--ink-soft); }
.folio__meta dd { margin: 0; }
.folio__meta a { color: var(--wine); text-decoration: underline 1px; text-underline-offset: 3px; }
.folio__meta .is-muted { color: var(--ink-soft); font-style: italic; }
.folio__chips { display: flex; flex-wrap: wrap; gap: 0.3rem 0.4rem; }
.folio__section {
margin: 2.2rem 0 0.8rem;
font-size: 1.35rem;
font-weight: 500;
font-style: italic;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.05rem 0.55rem 0.1rem 0.35rem;
border: 1px solid transparent;
border-radius: 2rem;
background: none;
cursor: pointer;
font-size: 0.98rem;
line-height: 1.4;
}
.chip__thread { width: 1.1rem; height: 2px; background: var(--ink); transition: width 0.25s var(--ease); }
.chip:hover { border-color: var(--rule); }
.chip:hover .chip__thread { width: 1.5rem; }
.chip.is-selected { border-color: var(--ink); background: rgb(255 255 255 / 0.35); font-weight: 600; }
.parts { list-style: none; margin: 0; padding: 0; counter-reset: none; }
.part {
position: relative;
padding: 0.8rem 0 0.9rem 1.4rem;
border-left: 1px solid var(--rule);
transition: border-color 0.3s, background-color 0.3s;
}
.part + .part { margin-top: 0.5rem; }
.part.is-motif { border-left: 3px solid var(--motif-ink); padding-left: calc(1.4rem - 2px); background: linear-gradient(90deg, rgb(255 255 255 / 0.4), transparent 80%); }
.part__head { margin: 0; display: flex; align-items: baseline; gap: 0.8rem; }
.part__name { font-style: italic; color: var(--ink-soft); min-width: 4rem; }
.part__singers { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; font-size: 1.08rem; }
.part__lines { margin: 0.35rem 0 0.5rem; }
.part__lines p { margin: 0; color: var(--ink-soft); font-style: italic; font-size: 1.04rem; }
.part__motifs { display: flex; flex-wrap: wrap; gap: 0.2rem 0.3rem; margin-left: -0.35rem; }
.part__motifs .chip { font-size: 0.9rem; }
.folio__fact {
margin-top: 2.4rem;
padding: 0.2rem 0 0.2rem 1.4rem;
border-left: 1px solid var(--ink);
}
.folio__fact .folio__section { margin-top: 0; }
.facts { margin: 0; padding: 0; list-style: none; font-size: 1.12rem; }
/* shared modules, re-inked */
.folio .audio-slot {
border: 1px dashed var(--ink-faint);
border-radius: 2px;
padding: 0.35rem 0.8rem;
font-style: italic;
color: var(--ink-soft);
opacity: 1;
}
.folio .audio-slot audio { margin-bottom: 0.4rem; }
.folio .audio-slot__seek { border: 1px solid var(--rule); background: none; border-radius: 2px; padding: 0.1rem 0.5rem; cursor: pointer; }
.folio .video-cards { gap: 0.6rem; }
.folio .video-card {
padding: 0.35rem 0.8rem 0.45rem;
border: 1px solid var(--rule);
border-radius: 2px;
background: rgb(255 255 255 / 0.3);
transition: border-color 0.2s, color 0.2s;
}
.folio .video-card::before { content: "YouTube"; display: block; font-size: 0.82rem; font-style: italic; color: var(--ink-soft); }
.folio .video-card:hover { opacity: 1; border-color: var(--wine); color: var(--wine); }
/* the timeline makes room for the folio on wide screens */
@media (min-width: 761px) {
.below { transition: margin-right 0.55s var(--ease); }
.folio-open .below { margin-right: calc(var(--folio-w) + 2rem); grid-template-columns: minmax(0, 1fr); }
.folio-open .progress { display: none; }
/* keep the fan credit fully visible beside the open folio */
.fan-credits { transition: margin-right 0.55s var(--ease); }
.folio-open .fan-credits { margin-right: calc(var(--folio-w) + 2rem); }
}
/* credits */
.fan-credits {
margin: 1.6rem 3rem 0;
padding: 1rem 0 1.4rem;
border-top: 1px solid var(--rule);
text-align: left;
font-style: italic;
font-size: 0.92rem;
color: var(--ink-soft);
opacity: 1;
}
/* load: the line is drawn once, left to right */
@media (prefers-reduced-motion: no-preference) {
.line__main { stroke-dasharray: 6000; stroke-dashoffset: 6000; }
.is-ready .line__main { stroke-dashoffset: 0; transition: stroke-dashoffset 1.6s var(--ease); }
.thread__live { opacity: 0; }
.is-ready .thread__live { opacity: 1; transition: opacity 0.9s 0.7s, stroke-width 0.3s; }
}
.no-anim *, .no-anim *::before, .no-anim *::after { transition: none !important; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0s !important; }
}
@media (max-width: 760px) {
body { font-size: 17px; }
.masthead { grid-template-columns: 1fr; margin: 0 1rem; padding-top: 1.4rem; }
.masthead__dek { justify-self: start; }
.margin { width: 11rem; }
.margin__group { left: 1rem; }
.motif { left: 0.5rem; }
.motif__swatch { width: 0.9rem; }
.below { grid-template-columns: 1fr; margin-inline: 1rem; }
.fan-credits { margin-inline: 1rem; }
.folio__bar, .folio__body { padding-inline: 1.2rem; }
.folio__bar { flex-wrap: wrap; row-gap: 0.2rem; padding-block: 0.7rem; }
.folio__where { flex-basis: 100%; }
.folio__nav { margin-left: -0.55rem; margin-right: auto; }
.voyage { --fade-l: linear-gradient(90deg, transparent 0, transparent 9.5rem, #000 11.5rem); }
.folio__title { font-size: 2.6rem; }
.folio__meta { grid-template-columns: 1fr; gap: 0.2rem; }
.folio__meta dd + dt { margin-top: 0.6rem; }
}