From 24f9ecaaadb17339ac2af61c8c005062e21c58fb Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 23 Oct 2026 18:38:24 +0200 Subject: [PATCH] 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 --- src/mockups/01-editorial/main.ts | 527 ++++++++++++++++++++++++++++- src/mockups/01-editorial/style.css | 490 ++++++++++++++++++++++++++- 2 files changed, 1014 insertions(+), 3 deletions(-) diff --git a/src/mockups/01-editorial/main.ts b/src/mockups/01-editorial/main.ts index bd5b800..2e0b6d7 100644 --- a/src/mockups/01-editorial/main.ts +++ b/src/mockups/01-editorial/main.ts @@ -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 = { + 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(); +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; +function h(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(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 = `

Editorial scroll

stub

`; + +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(); +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(); +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(); +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(".part").forEach((p) => { + const ids = (p.dataset.motifs ?? "").split(" "); + p.classList.toggle("is-motif", !!selectedMotif && ids.includes(selectedMotif)); + }); + folio.querySelectorAll(".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(".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= and ?motif= 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")); diff --git a/src/mockups/01-editorial/style.css b/src/mockups/01-editorial/style.css index e76b129..1e17c2f 100644 --- a/src/mockups/01-editorial/style.css +++ b/src/mockups/01-editorial/style.css @@ -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,"); + 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; } +}