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:
1 parent
80f6c97127
commit
24f9ecaaad
2 files changed
+1014
-3
No files matched your search
@@ -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"));
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user