feat(mockups): 05 score / waveform

Engraved full score: songs as measures, parts as waveform segments,
motifs as notehead staves with ties across recurrences, part cue buttons.

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:48:42 +02:00
1 parent 7da49cca31
commit 4884a39f0d
3 files changed
+1222 -3

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
// Geometry for the score: every song is a measure, every part a sub-segment, gaps in story time are rests.
import { motifs, sagas, songs } from "../../data/sample";
import { gapYears } from "../../data/query";
import type { Motif, Part, Song } from "../../data/types";
export const PART_W = 128;
export const MEASURE_PAD = 14;
export const REST_W = 104;
export const LEAD = 28;
export const Y = {
sagaText: 16,
sagaLine: 24,
markTop: 36,
markBottom: 58,
tempo: 80,
waveTop: 96,
waveH: 168,
partText: 288,
motifTop: 312,
rowH: 36,
groupGap: 14,
} as const;
export const WAVE_MID = Y.waveTop + Y.waveH / 2;
export interface PartBox { song: Song; part: Part; index: number; x: number; w: number }
export interface Measure { song: Song; index: number; x: number; w: number; parts: PartBox[]; sagaEnd: boolean }
export interface Rest { x: number; w: number; years: number }
export interface SagaSpan { name: string; x: number; w: number }
export interface MotifRow { motif: Motif; y: number }
export interface Layout {
width: number;
height: number;
measures: Measure[];
rests: Rest[];
sagaSpans: SagaSpan[];
rows: MotifRow[];
groupBreakY: number;
}
// Themes first, then character themes, like woodwinds above strings.
export const orderedMotifs: Motif[] = [
...motifs.filter((m) => m.kind === "motif"),
...motifs.filter((m) => m.kind === "character"),
];
export function buildLayout(): Layout {
const measures: Measure[] = [];
const rests: Rest[] = [];
let x = LEAD;
songs.forEach((song, i) => {
const prev = songs[i - 1];
if (prev) {
const years = gapYears(prev, song);
if (years > 0) {
rests.push({ x, w: REST_W, years });
x += REST_W;
}
}
const w = song.parts.length * PART_W + MEASURE_PAD * 2;
const parts = song.parts.map((part, index) => ({
song, part, index, x: x + MEASURE_PAD + index * PART_W, w: PART_W,
}));
const next = songs[i + 1];
measures.push({ song, index: i, x, w, parts, sagaEnd: !next || next.sagaId !== song.sagaId });
x += w;
});
const sagaSpans: SagaSpan[] = [...sagas].sort((a, b) => a.order - b.order).flatMap((saga) => {
const ms = measures.filter((m) => m.song.sagaId === saga.id);
const first = ms[0];
const last = ms[ms.length - 1];
if (!first || !last) return [];
return [{ name: saga.name, x: first.x, w: last.x + last.w - first.x }];
});
let y: number = Y.motifTop;
let groupBreakY: number = y;
const rows: MotifRow[] = orderedMotifs.map((motif, i) => {
const prev = orderedMotifs[i - 1];
if (prev && prev.kind !== motif.kind) {
y += Y.groupGap;
groupBreakY = y;
}
const row = { motif, y: y + Y.rowH / 2 };
y += Y.rowH;
return row;
});
return { width: x + LEAD, height: y + 10, measures, rests, sagaSpans, rows, groupBreakY };
}
// ---- Procedural waveform -------------------------------------------------
function hash(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
return h >>> 0;
}
function rng(seed: number): () => number {
let a = seed;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// How hard each song hits, so the waveform reads like the soundtrack's dynamics. Invented for the mockup.
const INTENSITY: Record<string, number> = {
"the-horse-and-the-infant": 0.78, "just-a-man": 0.5, "full-speed-ahead": 0.82, "open-arms": 0.56,
"warrior-of-the-mind": 0.86, polyphemus: 0.9, survive: 0.96, "remember-them": 0.6,
"my-goodbye": 0.72, storm: 1,
};
export const BAR_STEP = 4;
export const BAR_W = 2.4;
/** Bar amplitudes (0..1) for one part segment. */
export function partAmplitudes(p: PartBox): number[] {
const r = rng(hash(`${p.song.id}:${p.index}`));
const n = Math.floor((p.w - 8) / BAR_STEP);
const peak = INTENSITY[p.song.id] ?? 0.75;
const chorus = /chorus/i.test(p.part.name);
const last = p.index === p.song.parts.length - 1;
const phase = r() * Math.PI * 2;
const out: number[] = [];
for (let i = 0; i < n; i++) {
const t = i / Math.max(1, n - 1);
// Verses swell, choruses sit high, the final part of a song decays at the end.
let env = chorus ? 0.82 + 0.1 * Math.sin(t * Math.PI) : 0.32 + 0.6 * Math.pow(t, 0.8);
if (last && t > 0.8) env *= 1 - (t - 0.8) * 3.2;
if (p.index === 0 && t < 0.06) env *= t / 0.06;
const pulse = 0.78 + 0.22 * Math.sin(t * 38 + phase);
const grain = 0.55 + 0.45 * r();
out.push(Math.max(0.04, Math.min(1, env * pulse * grain * peak)));
}
return out;
}
/** SVG path of mirrored bars around `mid`, `half` = max half-height. */
export function wavePath(p: PartBox, mid: number, half: number, scaleX = 1, offsetX = 0): string {
const amps = partAmplitudes(p);
let d = "";
amps.forEach((a, i) => {
const x = offsetX + (p.x + 4 + i * BAR_STEP) * scaleX;
const h = Math.max(0.6, a * half);
const w = Math.max(0.6, BAR_W * scaleX);
d += `M${x.toFixed(1)} ${(mid - h).toFixed(1)}h${w.toFixed(2)}v${(h * 2).toFixed(1)}h${(-w).toFixed(2)}z`;
});
return d;
}
export function fmtTime(sec: number): string {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
+600 -1
View File
@@ -1,5 +1,604 @@
import { motifs, songs } from "../../data/sample";
import { motifsInSong, sagaOf, songsWithMotif } from "../../data/query";
import type { Motif, Part, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
WAVE_MID, Y, buildLayout, fmtTime, wavePath, type Layout, type PartBox,
} from "./layout";
mountCredits();
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
const layout: Layout = buildLayout();
const motifById = new Map(motifs.map((m) => [m.id, m]));
// Each theme gets its own notehead, the way percussion parts give each instrument a shape.
function glyph(id: string, cx: number, cy: number, s = 7, cls = "glyph"): string {
const m = motifById.get(id);
const fill = m?.color ?? "#999";
const a = `class="${cls}" data-glyph="${id}" style="--c:${fill}"`;
switch (id) {
case "warrior":
return `<path ${a} d="M${cx} ${cy - s * 1.15}L${cx + s} ${cy}L${cx} ${cy + s * 1.15}L${cx - s} ${cy}Z"/>`;
case "open-arms":
return `<g ${a} data-hollow><ellipse cx="${cx}" cy="${cy}" rx="${s * 1.05}" ry="${s * 0.68}" transform="rotate(-20 ${cx} ${cy})"/></g>`;
case "just-a-man":
return `<g ${a}><ellipse cx="${cx}" cy="${cy}" rx="${s * 1.05}" ry="${s * 0.7}" transform="rotate(-20 ${cx} ${cy})"/></g>`;
case "remember":
return `<path ${a} d="M${cx} ${cy - s}L${cx + s * 1.05} ${cy + s * 0.8}L${cx - s * 1.05} ${cy + s * 0.8}Z"/>`;
case "odysseus":
return `<rect ${a} x="${cx - s * 0.8}" y="${cy - s * 0.8}" width="${s * 1.6}" height="${s * 1.6}"/>`;
case "athena":
return `<g ${a}><circle cx="${cx}" cy="${cy}" r="${s * 0.9}"/><path d="M${cx - s * 0.9} ${cy}h${s * 1.8}M${cx} ${cy - s * 0.9}v${s * 1.8}"/></g>`;
case "polyphemus":
return `<path ${a} data-cross d="M${cx - s * 0.85} ${cy - s * 0.85}L${cx + s * 0.85} ${cy + s * 0.85}M${cx + s * 0.85} ${cy - s * 0.85}L${cx - s * 0.85} ${cy + s * 0.85}"/>`;
default:
return `<circle ${a} cx="${cx}" cy="${cy}" r="${s * 0.8}"/>`;
}
}
function glyphSvg(id: string, size = 18): string {
const h = size / 2;
return `<svg class="glyph-icon" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">${glyph(id, h, h, size * 0.36)}</svg>`;
}
// ---- Page shell ----------------------------------------------------------
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Score / waveform</h1><p>stub</p>`;
app.innerHTML = `
<header class="masthead">
<div class="title-block">
<h1><span class="title-epic">EPIC</span> <span class="title-sub">the voyage in full score</span></h1>
<p class="title-note">Ten songs as measures. Each part is a segment of the waveform; each theme is a row of noteheads below it.</p>
</div>
<p class="how">Drag or scroll sideways to pan. Click a measure to open the song. Click a theme name to follow it.</p>
</header>
<section class="score-wrap" aria-label="Score">
<div class="system">
<div class="instruments" id="instruments"></div>
<div class="scroller" id="scroller" tabindex="0" aria-label="Score, scroll sideways"><div id="score"></div></div>
</div>
<div class="overview" id="overview" aria-label="Whole score overview"></div>
</section>
<aside class="partbook" id="partbook" aria-live="polite"></aside>
`;
const scroller = document.getElementById("scroller")!;
const scoreHost = document.getElementById("score")!;
const instruments = document.getElementById("instruments")!;
const overviewHost = document.getElementById("overview")!;
const partbook = document.getElementById("partbook")!;
// ---- Instruments column (sticky motif names) ------------------------------
function renderInstruments(): void {
const H = layout.height;
const rowsSvg = layout.rows.map(({ motif, y }) => `
<g class="instr" data-motif="${motif.id}" data-motifs="${motif.id}" tabindex="0" role="button" aria-pressed="false"
aria-label="Follow the theme ${esc(motif.name)}">
<rect class="instr-hit" x="0" y="${y - Y.rowH / 2}" width="200" height="${Y.rowH}"/>
<text class="instr-name" x="148" y="${y + 4}" text-anchor="end">${esc(motif.name)}</text>
${glyph(motif.id, 170, y, 6.5)}
</g>`).join("");
const themes = layout.rows.filter((r) => r.motif.kind === "motif");
const chars = layout.rows.filter((r) => r.motif.kind === "character");
const bracket = (rows: typeof themes, label: string) => {
const a = rows[0];
const b = rows[rows.length - 1];
if (!a || !b) return "";
const top = a.y - Y.rowH / 2 + 4;
const bot = b.y + Y.rowH / 2 - 4;
return `<path class="brace" d="M196 ${top}h-6v${bot - top}h6"/>
<text class="group-label" transform="translate(16 ${(top + bot) / 2}) rotate(-90)" text-anchor="middle">${label}</text>`;
};
instruments.innerHTML = `
<svg width="200" height="${H}" viewBox="0 0 200 ${H}">
<text class="instr-head" x="186" y="${Y.tempo}" text-anchor="end">Story time</text>
<text class="instr-head instr-head--wave" x="186" y="${WAVE_MID + 4}" text-anchor="end">Song</text>
<text class="instr-head" x="186" y="${Y.partText}" text-anchor="end">Part</text>
${bracket(themes, "Themes")}
${bracket(chars, "Characters")}
${rowsSvg}
</svg>`;
}
// ---- The score ------------------------------------------------------------
function partMotifs(p: Part): string {
return p.motifIds.join(" ");
}
function renderScore(): void {
const { width: W, height: H } = layout;
const parts: string[] = [];
// Staff lines behind the waveform, and the single-line staves for every theme.
for (let k = -2; k <= 2; k++) {
parts.push(`<line class="staff" x1="0" x2="${W}" y1="${WAVE_MID + k * 30}" y2="${WAVE_MID + k * 30}"/>`);
}
for (const row of layout.rows) {
parts.push(`<line class="row-line" data-row="${row.motif.id}" x1="0" x2="${W}" y1="${row.y}" y2="${row.y}"/>`);
}
// Saga brackets, like the act headings in a vocal score.
for (const s of layout.sagaSpans) {
parts.push(`<path class="saga-bracket" d="M${s.x + 2} ${Y.sagaLine + 6}v-6h${s.w - 4}v6"/>
<text class="saga-name" x="${s.x + 6}" y="${Y.sagaText}">${esc(s.name)}</text>`);
}
// Rests: years that pass in the story between two songs, drawn as multi-measure rests.
for (const r of layout.rests) {
const cx = r.x + r.w / 2;
parts.push(`<g class="rest">
<line class="barline" x1="${r.x + r.w}" x2="${r.x + r.w}" y1="${Y.waveTop}" y2="${Y.waveTop + Y.waveH}"/>
<text class="rest-num" x="${cx}" y="${WAVE_MID - 26}" text-anchor="middle">${r.years}</text>
<rect class="rest-bar" x="${r.x + 20}" y="${WAVE_MID - 6}" width="${r.w - 40}" height="12"/>
<path class="rest-serif" d="M${r.x + 20} ${WAVE_MID - 14}v28M${r.x + r.w - 20} ${WAVE_MID - 14}v28"/>
<text class="rest-label" x="${cx}" y="${WAVE_MID + 36}" text-anchor="middle">${r.years === 1 ? "a year passes" : `${r.years} years pass`}</text>
</g>`);
}
for (const m of layout.measures) {
const { song } = m;
const songMotifs = motifsInSong(song).map((x) => x.id).join(" ");
const markW = String(song.track).length * 9 + 14;
const label = `${song.track}. ${song.title}. ${song.storyTime.label}. ${song.parts.length} part${song.parts.length > 1 ? "s" : ""}.`;
parts.push(`<g class="measure" data-song="${song.id}" data-motifs="${songMotifs}" tabindex="0" role="button" aria-label="${esc(label)}">
<rect class="measure-hit" x="${m.x}" y="0" width="${m.w}" height="${H}"/>
<rect class="measure-sel" x="${m.x}" y="${Y.waveTop - 4}" width="${m.w}" height="3"/>
<rect class="mark-box" x="${m.x + 6}" y="${Y.markTop}" width="${markW}" height="${Y.markBottom - Y.markTop}"/>
<text class="mark-num" x="${m.x + 6 + markW / 2}" y="${Y.markBottom - 6}" text-anchor="middle">${song.track}</text>
<text class="song-title" x="${m.x + markW + 14}" y="${Y.markBottom - 6}">${esc(song.title)}</text>
<text class="tempo" x="${m.x + 6}" y="${Y.tempo}">${esc(song.storyTime.label)}</text>
`);
for (const p of m.parts) {
const pm = partMotifs(p.part);
parts.push(`<g class="part" data-song="${song.id}" data-part="${p.index}" data-motifs="${pm}">
<rect class="part-wash" x="${p.x}" y="${Y.waveTop}" width="${p.w}" height="${Y.waveH}"/>
<path class="wave" d="${wavePath(p, WAVE_MID, Y.waveH / 2 - 8)}"/>
<text class="part-name" x="${p.x + 6}" y="${Y.partText}">${esc(p.part.name)}</text>
${cueMark(p)}
</g>`);
if (p.index > 0) {
parts.push(`<line class="part-bar" x1="${p.x}" x2="${p.x}" y1="${Y.waveTop}" y2="${Y.partText + 6}"/>`);
}
}
// Barline at the end of the song; a double bar where a saga closes.
const bx = m.x + m.w;
parts.push(m.sagaEnd
? `<line class="barline" x1="${bx - 5}" x2="${bx - 5}" y1="${Y.waveTop}" y2="${Y.waveTop + Y.waveH}"/>
<rect class="barline-thick" x="${bx - 2}" y="${Y.waveTop}" width="4" height="${Y.waveH}"/>`
: `<line class="barline" x1="${bx}" x2="${bx}" y1="${Y.waveTop}" y2="${Y.waveTop + Y.waveH}"/>`);
parts.push(`</g>`);
}
// Theme marks, plus the thin tie that joins each recurrence to the next.
for (const row of layout.rows) {
const hits: { p: PartBox; cx: number }[] = [];
for (const m of layout.measures) {
for (const p of m.parts) {
if (p.part.motifIds.includes(row.motif.id)) hits.push({ p, cx: p.x + p.w / 2 });
}
}
let ties = "";
hits.forEach((h, i) => {
const next = hits[i + 1];
if (!next) return;
const x1 = h.cx + 10;
const x2 = next.cx - 10;
const lift = Math.min(14, 4 + (x2 - x1) / 60);
ties += `M${x1} ${row.y - 4}Q${(x1 + x2) / 2} ${row.y - 4 - lift * 2} ${x2} ${row.y - 4}`;
});
parts.push(`<path class="tie" data-row="${row.motif.id}" style="--c:${row.motif.color}" d="${ties}"/>`);
for (const h of hits) {
parts.push(`<g class="mark" data-motifs="${row.motif.id}" data-song="${h.p.song.id}" data-part="${h.p.index}">
<line class="stem" x1="${h.cx}" x2="${h.cx}" y1="${Y.partText + 10}" y2="${row.y - 9}"/>
${glyph(row.motif.id, h.cx, row.y, 7)}
</g>`);
}
}
scoreHost.innerHTML = `<svg id="score-svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="group" aria-label="Score of the voyage">
${parts.join("")}
</svg>`;
}
function cueMark(p: PartBox): string {
if (p.part.startSec === undefined) return "";
const x = p.x + p.w - 8;
const t = fmtTime(p.part.startSec);
return `<g class="cue" data-cue="${p.song.id}:${p.index}" tabindex="0" role="button"
aria-label="Play ${esc(p.part.name)} of ${esc(p.song.title)} from ${t}">
<rect class="cue-hit" x="${x - 46}" y="${Y.partText - 14}" width="50" height="20" rx="10"/>
<path class="cue-tri" d="M${x - 38} ${Y.partText - 9}l7 4.5l-7 4.5z"/>
<text class="cue-time" x="${x - 4}" y="${Y.partText}" text-anchor="end">${t}</text>
</g>`;
}
// ---- Overview strip ---------------------------------------------------------
const OV_H = 46;
function renderOverview(): void {
const W = overviewHost.clientWidth || 1000;
const s = W / layout.width;
const mid = OV_H / 2 + 4;
let d = "";
for (const m of layout.measures) for (const p of m.parts) d += wavePath(p, mid, OV_H / 2 - 8, s);
const ticks = layout.measures.map((m) => {
const parts = m.parts.map((p) =>
`<rect class="ov-part" data-motifs="${partMotifs(p.part)}" x="${p.x * s}" y="2" width="${p.w * s}" height="${OV_H - 4}"/>`).join("");
return `${parts}<line class="ov-bar" x1="${(m.x + m.w) * s}" x2="${(m.x + m.w) * s}" y1="6" y2="${OV_H}"/>
<text class="ov-num" x="${m.x * s + 3}" y="11">${m.song.track}</text>`;
}).join("");
overviewHost.innerHTML = `<svg width="${W}" height="${OV_H + 4}" viewBox="0 0 ${W} ${OV_H + 4}">
${ticks}<path class="ov-wave" d="${d}"/><rect class="ov-window" id="ov-window" y="1" height="${OV_H + 2}" rx="3"/></svg>`;
syncOverview();
}
function syncOverview(): void {
const win = document.getElementById("ov-window");
if (!win) return;
const W = overviewHost.clientWidth || 1000;
const s = W / layout.width;
win.setAttribute("x", String(scroller.scrollLeft * s));
win.setAttribute("width", String(Math.min(W, scroller.clientWidth * s)));
}
function overviewJump(clientX: number): void {
const rect = overviewHost.getBoundingClientRect();
const s = rect.width / layout.width;
scroller.scrollLeft = (clientX - rect.left) / s - scroller.clientWidth / 2;
}
// ---- State ------------------------------------------------------------------
let selectedSong: string | null = null;
let selectedMotif: string | null = null;
function applyHighlights(): void {
const root = document.body;
root.classList.toggle("has-motif", !!selectedMotif);
root.classList.toggle("has-song", !!selectedSong);
const m = selectedMotif ? motifById.get(selectedMotif) : undefined;
if (m) root.style.setProperty("--lit", m.color);
document.querySelectorAll(".is-lit").forEach((el) => el.classList.remove("is-lit"));
if (selectedMotif) {
document.querySelectorAll(`[data-motifs~="${selectedMotif}"]`).forEach((el) => el.classList.add("is-lit"));
document.querySelectorAll(`[data-row="${selectedMotif}"]`).forEach((el) => el.classList.add("is-lit"));
}
document.querySelectorAll(".instr").forEach((el) =>
el.setAttribute("aria-pressed", String(el.getAttribute("data-motif") === selectedMotif)));
document.querySelectorAll(".measure").forEach((el) =>
el.classList.toggle("is-open", el.getAttribute("data-song") === selectedSong));
document.querySelectorAll(".part").forEach((el) =>
el.classList.toggle("is-open", el.getAttribute("data-song") === selectedSong));
}
function selectMotif(id: string | null): void {
selectedMotif = selectedMotif === id ? null : id;
applyHighlights();
renderPartbook();
}
function openSong(id: string | null, opts: { scroll?: boolean; instant?: boolean; cuePart?: number } = {}): void {
selectedSong = id;
applyHighlights();
renderPartbook(opts.cuePart);
if (id && opts.scroll) {
const m = layout.measures.find((x) => x.song.id === id);
if (m) scroller.scrollTo({ left: m.x - 60, behavior: opts.instant || matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
}
}
function cuePart(songId: string, partIndex: number): void {
document.querySelectorAll(".part.is-cued").forEach((el) => el.classList.remove("is-cued"));
document.querySelector(`.part[data-song="${songId}"][data-part="${partIndex}"]`)?.classList.add("is-cued");
}
// ---- Part book (detail) -----------------------------------------------------
function chip(m: Motif): string {
const on = m.id === selectedMotif;
return `<button type="button" class="chip${on ? " is-on" : ""}" data-chip="${m.id}" aria-pressed="${on}">${glyphSvg(m.id, 16)}<span>${esc(m.name)}</span></button>`;
}
function renderPartbook(cue?: number): void {
const song = selectedSong ? songs.find((s) => s.id === selectedSong) : undefined;
if (song) {
renderSong(song, cue);
return;
}
const motif = selectedMotif ? motifById.get(selectedMotif) : undefined;
if (motif) {
renderMotif(motif);
return;
}
renderKey();
}
function renderKey(): void {
partbook.innerHTML = `
<div class="pb-head"><h2 class="pb-title">How to read the score</h2></div>
<dl class="key">
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true"><path class="key-wave" d="M2 8h2v8h-2zM7 4h2v16h-2zM12 6h2v12h-2zM17 2h2v20h-2zM22 7h2v10h-2zM27 5h2v14h-2z"/><line class="key-bar" x1="34" x2="34" y1="0" y2="24"/><path class="key-wave" d="M38 9h2v6h-2zM43 3h2v18h-2zM48 6h2v12h-2z"/></svg></dt>
<dd>Each measure is a song. Dashed bars split it into its parts, and the height of the wave follows how loud the part is.</dd></div>
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true"><text class="key-num" x="27" y="8" text-anchor="middle">2</text><rect class="key-rest" x="10" y="12" width="34" height="7"/><path class="key-bar" d="M10 9v13M44 9v13"/></svg></dt>
<dd>A rest stands for years passing in the story. The number is how many.</dd></div>
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true">${glyph("warrior", 12, 12, 6)}${glyph("remember", 27, 12, 6)}${glyph("polyphemus", 42, 12, 6)}</svg></dt>
<dd>Each row below the waveform is one theme. A notehead marks every part where that theme plays, so a theme's returns line up along its row.</dd></div>
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true"><rect class="key-cue" x="4" y="2" width="46" height="20" rx="10"/><path class="key-tri" d="M12 7l7 5l-7 5z"/><text class="key-time" x="44" y="16" text-anchor="end">0:45</text></svg></dt>
<dd>A cue marks where a part starts in the recording. With an audio file attached, clicking it plays the song from there.</dd></div>
</dl>
<div class="pb-start">
<p>Try following <button type="button" class="link" data-chip="warrior">Warrior of the Mind</button> from Troy to the storm, or open <button type="button" class="link" data-open="the-horse-and-the-infant">the first song</button>.</p>
</div>`;
}
function renderMotif(m: Motif): void {
const list = songsWithMotif(m.id);
const partCount = list.reduce((n, s) => n + s.parts.filter((p) => p.motifIds.includes(m.id)).length, 0);
partbook.innerHTML = `
<div class="pb-head">
<div class="pb-glyph">${glyphSvg(m.id, 34)}</div>
<div>
<h2 class="pb-title">${esc(m.name)}</h2>
<p class="pb-sub">${m.kind === "character" ? "Character theme" : "Theme"}, heard in ${partCount} parts across ${list.length} songs</p>
</div>
<button type="button" class="close" data-clear-motif aria-label="Stop following this theme">Clear</button>
</div>
<p class="pb-desc">${esc(m.description)}</p>
<ol class="recur">
${list.map((s) => `<li><button type="button" class="recur-song" data-open="${s.id}">
<span class="recur-num">${s.track}</span><span class="recur-title">${esc(s.title)}</span>
<span class="recur-parts">${s.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => esc(p.name)).join(", ")}</span>
</button></li>`).join("")}
</ol>`;
}
function renderSong(song: Song, cue?: number): void {
const saga = sagaOf(song);
const ody = song.odyssey
? `<a href="${esc(song.odyssey.url)}" target="_blank" rel="noopener noreferrer">Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines)}</a>`
: `<span class="muted">No counterpart in Homer</span>`;
const years = song.storyTime.yearsAfterTroy;
partbook.innerHTML = `
<div class="pb-head">
<span class="pb-mark">${song.track}</span>
<div>
<h2 class="pb-title">${esc(song.title)}</h2>
<p class="pb-sub">${esc(saga.name)}</p>
</div>
<button type="button" class="close" data-close aria-label="Close song">Close</button>
</div>
<dl class="facts">
<div><dt>Story time</dt><dd>${esc(song.storyTime.label)} <span class="muted">(${years === 0 ? "the year Troy falls" : `${years} year${years > 1 ? "s" : ""} after Troy`})</span></dd></div>
<div><dt>The Odyssey</dt><dd>${ody}</dd></div>
</dl>
<ol class="parts">
${song.parts.map((p, i) => `
<li class="pb-part${p.motifIds.includes(selectedMotif ?? "") ? " is-lit" : ""}" data-pb-part="${i}">
<div class="pb-part-head">
<h3>${esc(p.name)}</h3>
<p class="singers">${p.singers.map(esc).join(" and ")}</p>
${p.startSec !== undefined
? `<button type="button" class="play" data-play="${i}" aria-label="Play ${esc(p.name)} from ${fmtTime(p.startSec)}"><svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M1 0l9 5l-9 5z"/></svg>${fmtTime(p.startSec)}</button>`
: `<span class="no-cue">no cue yet</span>`}
</div>
<div class="lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
<div class="chips">${p.motifIds.flatMap((id) => motifById.get(id) ?? []).map(chip).join("")}</div>
</li>`).join("")}
</ol>
${song.facts.length ? `<div class="fact"><h3>Did you know</h3>${song.facts.map((f) => `<p>${esc(f)}</p>`).join("")}</div>` : ""}
<div class="listen">
<h3>Listen and watch</h3>
<div id="audio-host"></div>
<p class="cue-note" id="cue-note" hidden></p>
<div id="video-host"></div>
</div>`;
document.getElementById("audio-host")!.append(createAudioSlot(song));
document.getElementById("video-host")!.append(createVideoCards(song));
partbook.scrollTop = 0;
if (cue !== undefined) playPart(song, cue);
}
function playPart(song: Song, index: number): void {
const part = song.parts[index];
if (!part || part.startSec === undefined) return;
cuePart(song.id, index);
partbook.querySelectorAll(".pb-part.is-cued").forEach((el) => el.classList.remove("is-cued"));
const li = partbook.querySelector(`[data-pb-part="${index}"]`);
li?.classList.add("is-cued");
const audio = partbook.querySelector<HTMLAudioElement>(".audio-slot audio");
const note = document.getElementById("cue-note");
if (audio) {
audio.currentTime = part.startSec;
void audio.play?.();
if (note) note.hidden = true;
return;
}
if (note) {
note.hidden = false;
note.textContent = `${part.name} starts at ${fmtTime(part.startSec)}. Once an audio file is added for this song, this cue plays from there.`;
}
li?.scrollIntoView({ block: "nearest" });
}
// ---- Events -------------------------------------------------------------------
function songFromTarget(t: Element): string | null {
return t.closest<SVGGElement>(".measure")?.dataset.song ?? null;
}
let dragMoved = false;
scoreHost.addEventListener("click", (e) => {
if (dragMoved) return;
const t = e.target as Element;
const cue = t.closest<SVGGElement>(".cue");
if (cue?.dataset.cue) {
const [sid, idx] = cue.dataset.cue.split(":");
openSong(sid ?? null, { cuePart: Number(idx) });
return;
}
const mark = t.closest<SVGGElement>(".mark");
if (mark?.dataset.motifs) {
selectMotif(mark.dataset.motifs);
return;
}
const sid = songFromTarget(t);
if (sid) openSong(selectedSong === sid ? null : sid);
});
scoreHost.addEventListener("keydown", (e) => {
const t = e.target as Element;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const cue = t.closest<SVGGElement>(".cue");
if (cue?.dataset.cue) {
const [sid, idx] = cue.dataset.cue.split(":");
openSong(sid ?? null, { cuePart: Number(idx) });
return;
}
const sid = songFromTarget(t);
if (sid) openSong(sid);
}
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
const all = [...scoreHost.querySelectorAll<SVGGElement>(".measure")];
const i = all.indexOf(t.closest(".measure") as SVGGElement);
const next = all[i + (e.key === "ArrowRight" ? 1 : -1)];
if (i >= 0 && next) {
e.preventDefault();
next.focus();
}
}
});
// Hover: light the whole measure column, the way a conductor's eye drops down a page.
scoreHost.addEventListener("pointerover", (e) => {
const sid = songFromTarget(e.target as Element);
document.querySelectorAll(".measure.is-hover").forEach((el) => el.classList.remove("is-hover"));
if (sid) document.querySelector(`.measure[data-song="${sid}"]`)?.classList.add("is-hover");
});
scoreHost.addEventListener("pointerleave", () =>
document.querySelectorAll(".measure.is-hover").forEach((el) => el.classList.remove("is-hover")));
instruments.addEventListener("click", (e) => {
const g = (e.target as Element).closest<SVGGElement>(".instr");
if (g?.dataset.motif) selectMotif(g.dataset.motif);
});
instruments.addEventListener("keydown", (e) => {
const g = (e.target as Element).closest<SVGGElement>(".instr");
if (g?.dataset.motif && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
selectMotif(g.dataset.motif);
}
});
partbook.addEventListener("click", (e) => {
const t = e.target as Element;
const chipEl = t.closest<HTMLElement>("[data-chip]");
if (chipEl?.dataset.chip) {
selectMotif(chipEl.dataset.chip);
return;
}
const open = t.closest<HTMLElement>("[data-open]");
if (open?.dataset.open) {
openSong(open.dataset.open, { scroll: true });
return;
}
if (t.closest("[data-close]")) {
openSong(null);
return;
}
if (t.closest("[data-clear-motif]")) {
selectMotif(selectedMotif);
return;
}
const play = t.closest<HTMLElement>("[data-play]");
if (play?.dataset.play && selectedSong) {
const song = songs.find((s) => s.id === selectedSong);
if (song) playPart(song, Number(play.dataset.play));
}
});
// Hovering a part in the part book points at its segment in the score.
partbook.addEventListener("pointerover", (e) => {
const li = (e.target as Element).closest<HTMLElement>("[data-pb-part]");
document.querySelectorAll(".part.is-pointed").forEach((el) => el.classList.remove("is-pointed"));
if (li && selectedSong) {
document.querySelector(`.part[data-song="${selectedSong}"][data-part="${li.dataset.pbPart}"]`)?.classList.add("is-pointed");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (selectedSong) openSong(null);
else if (selectedMotif) selectMotif(selectedMotif);
}
});
// Panning: vertical wheel moves the score sideways; drag with the pointer like sliding a page.
scroller.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
scroller.scrollLeft += e.deltaY;
e.preventDefault();
}
}, { passive: false });
let dragStart: { x: number; left: number } | null = null;
scroller.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragStart = { x: e.clientX, left: scroller.scrollLeft };
dragMoved = false;
});
window.addEventListener("pointermove", (e) => {
if (!dragStart) return;
const dx = e.clientX - dragStart.x;
if (Math.abs(dx) > 4) {
dragMoved = true;
scroller.classList.add("is-dragging");
}
if (dragMoved) scroller.scrollLeft = dragStart.left - dx;
});
window.addEventListener("pointerup", () => {
dragStart = null;
scroller.classList.remove("is-dragging");
setTimeout(() => (dragMoved = false), 0);
});
scroller.addEventListener("scroll", syncOverview, { passive: true });
let ovDrag = false;
overviewHost.addEventListener("pointerdown", (e) => {
ovDrag = true;
overviewJump(e.clientX);
});
window.addEventListener("pointermove", (e) => { if (ovDrag) overviewJump(e.clientX); });
window.addEventListener("pointerup", () => (ovDrag = false));
window.addEventListener("resize", () => {
renderOverview();
applyHighlights();
});
// ---- Boot -------------------------------------------------------------------------
renderInstruments();
renderScore();
renderOverview();
const params = new URLSearchParams(location.search);
const qMotif = params.get("motif");
const qSong = params.get("song");
if (qMotif && motifById.has(qMotif)) selectedMotif = qMotif;
applyHighlights();
if (qSong && songs.some((s) => s.id === qSong)) {
const cue = params.get("cue");
openSong(qSong, { scroll: true, instant: true, cuePart: cue !== null ? Number(cue) : undefined });
} else {
renderPartbook();
}
// Orchestrate one entrance: the waveform draws in left to right, once.
document.body.classList.add("is-ready");
+460 -2
View File
@@ -1,2 +1,460 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "05 · "; }
/* 05 · Score / waveform — an engraved full score on cool manuscript paper. */
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-400-italic.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-500-italic.css";
@import "@fontsource/inter/latin-600.css";
@import "@fontsource/inter/latin-800.css";
:root {
color-scheme: light;
--paper: #e4e8e5; /* manuscript paper, cool and slightly green */
--sheet: #f3f5f2; /* the part book, a fresher leaf on top */
--ink: #1a1e27; /* engraving ink, blue-black */
--ink-2: #4b525d;
--ink-3: #7d858d;
--staff: #a9b1b5;
--hair: #c8cfcd;
--cobalt: #2c43a6; /* conductor's pencil: selection and cues */
--lit: #d9a441;
--sans: "Inter", system-ui, sans-serif;
--instr-w: 200px;
--book-w: 420px;
}
* { box-sizing: border-box; }
html { background: var(--paper); }
body {
margin: 0;
height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--book-w);
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas: "head head" "score book" "foot foot";
background: var(--paper);
color: var(--ink);
font: 400 15px/1.5 var(--sans);
font-feature-settings: "tnum" 1, "cv11" 1;
overflow: hidden;
}
main#app { display: contents; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--cobalt); outline-offset: 2px; }
/* ---- Masthead ------------------------------------------------------------ */
.masthead {
grid-area: head;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
padding: 1.1rem 1.75rem 0.9rem calc(var(--instr-w) - 52px);
border-bottom: 1px solid var(--hair);
}
.masthead h1 {
margin: 0;
display: flex;
align-items: baseline;
gap: 0.9rem;
font-weight: 400;
}
.title-epic {
font-weight: 800;
font-size: 2.6rem;
line-height: 0.9;
letter-spacing: -0.045em;
}
.title-sub {
font-style: italic;
font-weight: 500;
font-size: 1.15rem;
color: var(--ink-2);
}
.title-note {
margin: 0.4rem 0 0;
max-width: 62ch;
color: var(--ink-2);
font-size: 0.86rem;
}
.how {
margin: 0;
max-width: 30ch;
text-align: right;
font-size: 0.8rem;
color: var(--ink-3);
}
/* ---- Score ------------------------------------------------------------------ */
.score-wrap {
grid-area: score;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding-top: 0.6rem;
}
.system { display: flex; flex: none; }
.instruments {
flex: none;
width: var(--instr-w);
border-right: 1.5px solid var(--ink);
overflow: hidden;
}
.scroller {
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
cursor: grab;
scrollbar-width: thin;
scrollbar-color: var(--staff) transparent;
}
.scroller.is-dragging { cursor: grabbing; user-select: none; }
.scroller:focus-visible { outline-offset: -2px; }
#score svg { display: block; }
svg text { font-family: var(--sans); fill: var(--ink); }
.staff { stroke: var(--staff); stroke-width: 0.8; opacity: 0.55; }
.row-line { stroke: var(--staff); stroke-width: 0.8; }
.row-line.is-lit { stroke: var(--lit); stroke-width: 1.6; }
.saga-bracket { fill: none; stroke: var(--ink); stroke-width: 1.2; }
.saga-name { font-size: 12px; font-weight: 600; fill: var(--ink-2); }
.barline { stroke: var(--ink); stroke-width: 1.3; }
.barline-thick { fill: var(--ink); }
.part-bar { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 3 4; }
.rest-bar { fill: var(--ink); }
.rest-serif { stroke: var(--ink); stroke-width: 1.4; }
.rest-num { font-size: 22px; font-weight: 800; }
.rest-label { font-size: 11px; font-style: italic; fill: var(--ink-2); }
.measure { cursor: pointer; outline: none; }
.measure-hit { fill: transparent; transition: fill 160ms ease; }
.measure.is-hover .measure-hit { fill: rgb(44 67 166 / 0.045); }
.measure.is-open .measure-hit { fill: rgb(44 67 166 / 0.065); }
.measure:focus-visible .measure-hit { fill: rgb(44 67 166 / 0.06); stroke: var(--cobalt); stroke-width: 1.5; }
.measure-sel { fill: transparent; }
.measure.is-open .measure-sel { fill: var(--cobalt); }
.mark-box { fill: none; stroke: var(--ink); stroke-width: 1.4; }
.mark-num { font-size: 14px; font-weight: 800; }
.song-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
.measure.is-open .song-title, .measure.is-open .mark-num { fill: var(--cobalt); }
.measure.is-open .mark-box { stroke: var(--cobalt); }
.tempo { font-size: 12px; font-style: italic; font-weight: 500; fill: var(--ink-2); }
.part-wash { fill: transparent; }
.wave { fill: var(--ink); opacity: 0.88; transition: fill 220ms ease, opacity 220ms ease; }
.part-name { font-size: 11.5px; font-weight: 500; fill: var(--ink-2); }
.part.is-pointed .part-wash, .part.is-cued .part-wash { fill: rgb(44 67 166 / 0.08); }
.part.is-cued .wave { fill: var(--cobalt); }
.cue { cursor: pointer; outline: none; }
.cue-hit { fill: var(--sheet); stroke: var(--ink-3); stroke-width: 0.8; }
.cue-tri { fill: var(--cobalt); }
.cue-time { font-size: 11px; font-weight: 600; fill: var(--cobalt); }
.cue:hover .cue-hit, .cue:focus-visible .cue-hit { fill: var(--cobalt); stroke: var(--cobalt); }
.cue:hover .cue-tri, .cue:focus-visible .cue-tri,
.cue:hover .cue-time, .cue:focus-visible .cue-time { fill: var(--sheet); }
/* Noteheads. Each theme has a shape and its colour; ink outlines keep pale colours legible on paper. */
.glyph { fill: var(--c); stroke: var(--ink); stroke-width: 1.2; stroke-linejoin: round; }
.glyph[data-hollow] { fill: var(--sheet); stroke: color-mix(in oklab, var(--c) 70%, var(--ink)); stroke-width: 2.8; }
.glyph[data-cross] { fill: none; stroke: color-mix(in oklab, var(--c) 80%, var(--ink)); stroke-width: 3; stroke-linecap: round; }
g.glyph[data-glyph="athena"] path { stroke-width: 1; fill: none; }
.stem { stroke: var(--ink); stroke-width: 0.9; opacity: 0; }
.mark { cursor: pointer; }
.mark .glyph { transition: transform 200ms ease; transform-box: fill-box; transform-origin: center; }
.mark:hover .glyph { transform: scale(1.25); }
.tie { fill: none; stroke: var(--ink-3); stroke-width: 0.9; stroke-dasharray: 2 3; }
.tie.is-lit { stroke: color-mix(in oklab, var(--c) 75%, var(--ink)); stroke-width: 1.8; stroke-dasharray: none; }
/* A theme is selected: everything else drops back, its returns light up across the whole score. */
.has-motif .wave { fill: var(--ink-3); opacity: 0.32; }
.has-motif .part.is-lit .wave { fill: color-mix(in oklab, var(--lit) 78%, var(--ink)); opacity: 1; }
.has-motif .part.is-lit .part-wash { fill: color-mix(in oklab, var(--lit) 14%, transparent); }
.has-motif .part.is-lit .part-name { fill: var(--ink); font-weight: 600; }
.has-motif .mark { opacity: 0.25; }
.has-motif .mark.is-lit { opacity: 1; }
.has-motif .mark.is-lit .stem { opacity: 0.5; stroke: color-mix(in oklab, var(--lit) 70%, var(--ink)); }
.has-motif .mark.is-lit .glyph { transform: scale(1.3); }
.has-motif .measure:not(.is-lit) .song-title,
.has-motif .measure:not(.is-lit) .mark-num { fill: var(--ink-3); }
.has-motif .measure:not(.is-lit) .mark-box { stroke: var(--ink-3); }
/* Instruments column */
.instr { cursor: pointer; outline: none; }
.instr-hit { fill: transparent; }
.instr:hover .instr-hit { fill: rgb(26 30 39 / 0.04); }
.instr:focus-visible .instr-hit { stroke: var(--cobalt); stroke-width: 1.5; }
.instr-name { font-size: 12.5px; font-weight: 500; }
.instr.is-lit .instr-hit { fill: color-mix(in oklab, var(--lit) 22%, transparent); }
.instr.is-lit .instr-name { font-weight: 600; }
.has-motif .instr:not(.is-lit) .instr-name { fill: var(--ink-3); }
.instr-head { font-size: 11px; font-style: italic; fill: var(--ink-3); }
.instr-head--wave { font-size: 13px; font-weight: 600; font-style: normal; fill: var(--ink); }
.brace { fill: none; stroke: var(--ink); stroke-width: 1.4; }
.group-label { font-size: 10.5px; font-style: italic; fill: var(--ink-3); }
/* Overview */
.overview {
flex: none;
margin: 0.7rem 1.25rem 0.8rem calc(var(--instr-w) + 0px);
height: 50px;
cursor: pointer;
touch-action: none;
}
.overview svg { display: block; }
.ov-wave { fill: var(--ink); opacity: 0.6; }
.ov-part { fill: transparent; }
.has-motif .ov-wave { opacity: 0.25; }
.has-motif .ov-part.is-lit { fill: var(--lit); fill-opacity: 0.45; }
.ov-bar { stroke: var(--staff); stroke-width: 1; }
.ov-num { font-size: 9px; font-weight: 600; fill: var(--ink-3); }
.ov-window { fill: rgb(44 67 166 / 0.06); stroke: var(--cobalt); stroke-width: 1.2; }
/* Entrance: the waveform rises from the centre line once. */
@media (prefers-reduced-motion: no-preference) {
.is-ready #score .wave { animation: rise 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both; transform-box: view-box; transform-origin: 0 180px; }
@keyframes rise { from { transform: scaleY(0.02); } to { transform: scaleY(1); } }
}
/* ---- Part book (detail) ---------------------------------------------------- */
.partbook {
grid-area: book;
min-height: 0;
overflow-y: auto;
background: var(--sheet);
border-left: 1px solid var(--hair);
padding: 1.4rem 1.6rem 2rem;
box-shadow: -1px 0 0 rgb(255 255 255 / 0.6) inset;
}
.partbook h3 { margin: 0; font-size: 0.95rem; font-weight: 600; }
.pb-head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 0.85rem;
margin-bottom: 1rem;
}
.pb-head:has(> :only-child) { grid-template-columns: 1fr; }
.pb-title { margin: 0; font-size: 1.45rem; line-height: 1.15; font-weight: 800; letter-spacing: -0.02em; }
.pb-sub { margin: 0.2rem 0 0; font-style: italic; color: var(--ink-2); font-size: 0.9rem; }
.pb-mark {
display: grid;
place-items: center;
min-width: 2.2rem;
height: 2.2rem;
border: 2px solid var(--cobalt);
color: var(--cobalt);
font-weight: 800;
font-size: 1.05rem;
}
.pb-glyph { padding-top: 0.15rem; }
.close {
border: 1px solid var(--ink-3);
background: transparent;
padding: 0.25rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
cursor: pointer;
}
.close:hover { border-color: var(--ink); }
.facts { margin: 0 0 1.2rem; display: grid; gap: 0.35rem; }
.facts div { display: grid; grid-template-columns: 6.5rem 1fr; gap: 0.75rem; }
.facts dt { color: var(--ink-3); font-size: 0.82rem; padding-top: 0.1rem; }
.facts dd { margin: 0; }
.facts a { color: var(--cobalt); text-underline-offset: 3px; }
.muted { color: var(--ink-3); }
.parts { list-style: none; margin: 0 0 1.3rem; padding: 0; border-top: 1.5px solid var(--ink); }
.pb-part {
padding: 0.85rem 0 0.9rem 0.9rem;
border-bottom: 1px solid var(--hair);
border-left: 3px solid transparent;
transition: background-color 200ms ease, border-color 200ms ease;
}
.pb-part.is-lit { border-left-color: var(--lit); background: color-mix(in oklab, var(--lit) 10%, transparent); }
.pb-part.is-cued { border-left-color: var(--cobalt); background: rgb(44 67 166 / 0.06); }
.pb-part-head { display: flex; align-items: baseline; gap: 0.7rem; flex-wrap: wrap; }
.singers { margin: 0; font-style: italic; color: var(--ink-2); font-size: 0.88rem; flex: 1; }
.play {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: var(--cobalt);
color: var(--sheet);
font-size: 0.78rem;
font-weight: 600;
padding: 0.2rem 0.65rem 0.2rem 0.55rem;
border-radius: 999px;
cursor: pointer;
}
.play svg { fill: currentColor; }
.play:hover { background: var(--ink); }
.no-cue { font-size: 0.75rem; color: var(--ink-3); }
.lines { margin: 0.45rem 0 0.55rem; color: var(--ink-2); font-size: 0.86rem; }
.lines p { margin: 0; }
.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.6rem 0.15rem 0.3rem;
border: 1px solid var(--hair);
background: var(--sheet);
border-radius: 999px;
font-size: 0.78rem;
cursor: pointer;
}
.chip:hover { border-color: var(--ink-2); }
.chip.is-on { border-color: var(--ink); background: color-mix(in oklab, var(--lit) 24%, var(--sheet)); font-weight: 600; }
.glyph-icon { display: block; overflow: visible; }
.fact { margin-bottom: 1.3rem; padding-left: 0.9rem; border-left: 3px solid var(--ink); }
.fact p { margin: 0.25rem 0 0; font-size: 0.9rem; }
.listen h3 { margin-bottom: 0.5rem; }
.listen .audio-slot {
border: 1.5px dashed var(--ink-3);
border-radius: 4px;
padding: 0.7rem 0.85rem;
color: var(--ink-2);
font-size: 0.86rem;
font-style: italic;
opacity: 1;
}
.listen .audio-slot__seek { font-size: 0.78rem; }
.cue-note {
margin: 0.5rem 0 0;
font-size: 0.82rem;
color: var(--cobalt);
}
.listen .video-cards { margin-top: 0.75rem; }
.listen .video-card {
border: 1px solid var(--ink-3);
border-radius: 4px;
padding: 0.5rem 0.75rem 0.5rem 2.1rem;
font-size: 0.85rem;
position: relative;
background: var(--paper);
}
.listen .video-card::before {
content: "";
position: absolute;
left: 0.75rem;
top: 50%;
translate: 0 -50%;
border-left: 8px solid var(--ink);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
.listen .video-card:hover { opacity: 1; border-color: var(--ink); }
/* Key and motif states */
.key { margin: 0 0 1.4rem; display: grid; gap: 1rem; }
.key div { display: grid; grid-template-columns: 58px 1fr; gap: 0.9rem; align-items: start; }
.key dd { margin: 0; font-size: 0.88rem; color: var(--ink-2); }
.key svg { display: block; overflow: visible; }
.key-wave { fill: var(--ink); }
.key-bar { stroke: var(--ink-3); stroke-dasharray: 3 3; }
.key-rest { fill: var(--ink); }
.key-num { font-size: 11px; font-weight: 800; }
.key-cue { fill: var(--sheet); stroke: var(--ink-3); }
.key-tri { fill: var(--cobalt); }
.key-time { font-size: 10px; font-weight: 600; fill: var(--cobalt); }
.pb-start p { margin: 0; font-size: 0.9rem; }
.link {
border: 0;
background: none;
padding: 0;
color: var(--cobalt);
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.pb-desc { margin: 0 0 1rem; }
.recur { list-style: none; margin: 0; padding: 0; border-top: 1.5px solid var(--ink); }
.recur-song {
width: 100%;
display: grid;
grid-template-columns: 2rem 1fr;
column-gap: 0.6rem;
text-align: left;
border: 0;
border-bottom: 1px solid var(--hair);
background: transparent;
padding: 0.6rem 0;
cursor: pointer;
}
.recur-song:hover { background: rgb(44 67 166 / 0.05); }
.recur-num { grid-row: span 2; font-weight: 800; text-align: center; border: 1.4px solid var(--ink); height: 1.6rem; line-height: 1.45rem; font-size: 0.85rem; }
.recur-title { font-weight: 600; }
.recur-parts { font-size: 0.8rem; font-style: italic; color: var(--ink-2); }
/* ---- Credits ------------------------------------------------------------------ */
.fan-credits {
grid-area: foot;
border-top: 1.5px solid var(--ink);
background: var(--paper);
color: var(--ink-2);
opacity: 1;
padding: 0.65rem 1.5rem;
font-size: 0.78rem;
}
/* ---- Narrow screens: stack and let the page scroll ------------------------------ */
@media (max-width: 900px) {
:root { --instr-w: 160px; }
body {
height: auto;
grid-template-rows: auto;
min-height: 100vh;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "head" "score" "book" "foot";
}
.masthead { flex-direction: column; align-items: flex-start; padding: 1rem; gap: 0.5rem; }
.how { text-align: left; }
.instruments svg { margin-left: -40px; }
.instr-name { font-size: 11px; }
.partbook { border-left: 0; border-top: 1px solid var(--hair); overflow: visible; }
.overview { margin-left: 1rem; }
}