feat(mockups): 04 greek frieze

Black-figure vase frieze panned sideways, painted SVG scenes per song,
glyph-shaped theme signs, red-figure song register.

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:52:28 +02:00
1 parent 4884a39f0d
commit 3bfd4ab456
4 files changed
+1014 -3

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
// Black-figure painting kit: abstract silhouettes built from simple shapes, drawn in "glaze"
// (fill: var(--ink)) with "incised" detail lines scratched back to the clay (stroke: var(--inc)).
// Every panel shares one 300-unit-tall coordinate space with the ground line at GROUND.
export const PANEL_H = 300;
const GROUND = 284;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw";
interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
}
const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
const ink = (d: string) => `<path class="ink" d="${d}"/>`;
// A limb: glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze.
const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
// back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5) + ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1 } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
function cave(w: number): string {
return ink(`M0,0 H${w} V${PANEL_H} H${w - 30} Q${w - 20},${66} ${w / 2},${58} Q30,66 32,${PANEL_H} H0 Z`) +
inc(`M${w - 22},120 q-6,-30 -14,-48 M20,150 q6,-40 18,-70 M${w * 0.3},40 q30,-6 60,-2 M${w * 0.62},44 q24,0 46,10`, 1.6);
}
export interface PanelArt { w: number; art: string; inscription?: "dark" | "light"; inscrEnd?: boolean }
const ground = (w: number) => `<path class="ground" d="M0,${GROUND} H${w}"/>`;
export const PANELS: Record<string, PanelArt> = {
"the-horse-and-the-infant": { w: 370, art: trojanHorse(132, 0.9) + person({ x: 300, f: -1, arms: "reach", helmet: true, infant: true }) },
"just-a-man": {
w: 240,
art: person({ x: 96, arms: "mourn", helmet: true, beard: true }) +
limb("M178,284 L192,70", 3.5) + ink("M192,70 L186,48 L200,66 Z") +
`<circle class="ink" cx="176" cy="250" r="32"/><circle class="inc" cx="176" cy="250" r="26" stroke-width="1.6"/>` +
inc("M162,236 l28,28 M190,236 l-28,28", 1.4),
},
"full-speed-ahead": { w: 390, art: waves(0, 390, 290) + ship(190, 276, 1, { sail: true }) },
"open-arms": {
w: 300,
art: person({ x: 92, arms: "open", beard: true }) + person({ x: 214, f: -1, arms: "reach", robe: true }),
},
"warrior-of-the-mind": {
w: 340,
art: person({ x: 98, arms: "spear", helmet: true, shield: true, robe: true }) +
person({ x: 246, f: -1, arms: "spear", helmet: true, shield: true, beard: true }),
},
polyphemus: {
w: 340,
art: person({ x: 76, arms: "reach", beard: true, cup: true }) +
person({ x: 252, f: -1, s: 1.2, arms: "club", cyclops: true, beard: true }),
},
survive: {
w: 340, inscription: "light",
art: person({ x: 214, f: -1, s: 1.02, arms: "down", cyclops: true, beard: true }) +
person({ x: 82, s: 0.62, arms: "mourn", kneel: true }) + person({ x: 120, s: 0.62, arms: "reach" }) + cave(340),
},
"remember-them": {
w: 310,
art: stele(155) + person({ x: 62, arms: "mourn", kneel: true, beard: true }) + person({ x: 250, f: -1, arms: "mourn", robe: true }),
},
"my-goodbye": {
w: 400, inscrEnd: true,
art: waves(160, 400, 290) + person({ x: 78, s: 1.15, arms: "throw", cyclops: true, beard: true }) +
ship(292, 276, 0.62, { sail: true }),
},
storm: {
w: 350,
art: bolt(52, 40, 1) + bolt(282, 52, 0.8) + ship(176, 252, 0.86, { tilt: -9 }) + waves(0, 350, 278, 1.3) + waves(18, 350, 296, 0.9),
},
};
const escXml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** The painted scene for a song: figures plus its name written in the field, as on a vase. */
export function panelSvg(id: string, title: string, withTitle = true): string {
const p = PANELS[id] ?? { w: 260, art: person({ x: 130 }) };
const light = p.inscription === "light";
const x = p.inscrEnd ? p.w - 16 : light ? 46 : 16;
const label = withTitle
? `<text class="inscr${light ? " inscr--light" : ""}" x="${x}" y="${light ? 32 : 28}"${p.inscrEnd ? ' text-anchor="end"' : ""}>${escXml(title.toUpperCase())}</text>`
: "";
return `<svg class="scene" viewBox="0 0 ${p.w} ${PANEL_H}" aria-hidden="true" focusable="false">${p.art}${ground(p.w)}${label}</svg>`;
}
export function panelWidth(id: string): number {
return PANELS[id]?.w ?? 260;
}
/** Narrow ladder band between consecutive songs of one saga. */
export function ladderSvg(): string {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return `<svg class="sep-art" viewBox="0 0 26 300" aria-hidden="true"><path class="rule" d="${d}"/></svg>`;
}
function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A saga boundary: palmettes framing the saga's name, painted vertically. */
export function sagaSvg(name: string, gap: number): string {
const tally = gap > 0
? `<text class="tally" x="66" y="296" text-anchor="middle">${gap} ${gap === 1 ? "year" : "years"} later</text>`
: "";
return `<svg class="sep-art" viewBox="0 0 132 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M128,0 V300"/>
${palmette(66, 52)}${palmette(66, gap > 0 ? 226 : 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${escXml(name.replace(/^The /, "").toUpperCase())}</text>
${tally}
</svg>`;
}
/** Elapsed story time between two songs, painted as a tally of years. */
export function gapSvg(gap: number): string {
const w = 40 + gap * 24;
let bars = "";
for (let i = 0; i < gap; i++) bars += `M${20 + i * 24 + 6},120 V180 `;
return `<svg class="sep-art" viewBox="0 0 ${w} 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M${w - 4},0 V300"/>
<path class="tally-bars" d="${bars}"/>
<text class="tally" transform="translate(${w / 2 + 4},250) rotate(-90)" >+${gap} yr</text>
</svg>`;
}
+23
View File
@@ -0,0 +1,23 @@
// Motif glyphs: each recurring theme is a small painted sign, the way vase painters repeat
// shield devices and filler ornaments. Glaze = currentColor; the "hole" colour is var(--hole).
const G: Record<string, string> = {
// Warrior of the Mind: a crested helmet
warrior: `<path fill="currentColor" d="M5 21v-8a7 7 0 0 1 14 0v8h-4v-5h-3.5v5z"/><path fill="currentColor" d="M5.5 9.5Q9 1 20 5.5Q12 4 7.5 11z"/>`,
// Open Arms: a figure welcoming, arms wide
"open-arms": `<circle fill="currentColor" cx="12" cy="5" r="2.8"/><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" d="M12 9v7m0 0-4 6m4-6 4 6M12 11 4.5 6.5M12 11l7.5-4.5"/>`,
// Just a Man: a lone figure, arms at rest, on the ground line
"just-a-man": `<circle fill="currentColor" cx="12" cy="4.5" r="2.8"/><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" d="M12 8.5v7.5m0 0-3 5m3-5 3 5M12 10.5l-3 5m3-5 3 5M4 22.5h16"/>`,
// Remember Them: a grave stele
remember: `<path fill="currentColor" d="M8 20V8l4-4.5L16 8v12zM6 20h12v2.5H6z"/><circle fill="var(--hole)" cx="12" cy="11" r="1.8"/>`,
// Odysseus: the bow
odysseus: `<path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" d="M7 2.5Q21 12 7 21.5M7 2.5v19M3 12h17m-3.5-3.5L20 12l-3.5 3.5"/>`,
// Athena: the owl
athena: `<path fill="currentColor" d="M5.5 7 7.5 2.5 10.5 6h3l3-3.5L18.5 7v8q0 6.5-6.5 7-6.5-.5-6.5-7z"/><circle fill="var(--hole)" cx="9.3" cy="10" r="2.2"/><circle fill="var(--hole)" cx="14.7" cy="10" r="2.2"/><path fill="var(--hole)" d="M11 13h2l-1 2.2z"/>`,
// Polyphemus: the single eye
polyphemus: `<path fill="currentColor" d="M1.5 12Q12 2.5 22.5 12 12 21.5 1.5 12z"/><circle fill="var(--hole)" cx="12" cy="12" r="4.3"/><circle fill="currentColor" cx="12" cy="12" r="2"/>`,
};
export function glyph(id: string, cls = "glyph"): string {
return `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${G[id] ?? `<circle fill="currentColor" cx="12" cy="12" r="6"/>`}</svg>`;
}
+325 -1
View File
@@ -1,5 +1,329 @@
import { motifs, sagas, songs } from "../../data/sample";
import { gapYears, sagaOf, songsWithMotif } from "../../data/query";
import type { Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import { PANEL_H, gapSvg, ladderSvg, panelSvg, panelWidth, sagaSvg } from "./figures";
import { glyph } from "./glyphs";
mountCredits();
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
const motifById = new Map(motifs.map((m) => [m.id, m]));
const songById = new Map(songs.map((s) => [s.id, s]));
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const ordered = [...songs].sort((a, b) => a.track - b.track);
const list = (xs: string[]) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
const yearsAfter = (y: number) => (y === 0 ? "the year Troy falls" : `${y} ${y === 1 ? "year" : "years"} after Troy`);
let openId: string | null = null;
let activeMotif: string | null = null;
// ---------- frieze columns ----------
function columns(): string {
let html = "";
ordered.forEach((song, i) => {
const prev = ordered[i - 1];
const gap = prev ? gapYears(prev, song) : 0;
if (!prev || prev.sagaId !== song.sagaId) {
const saga = sagaOf(song);
html += `<div class="col col--sep" data-saga="${saga.id}" style="--w:132">${sagaSvg(saga.name, gap)}</div>`;
} else if (gap > 0) {
html += `<div class="col col--sep" style="--w:${40 + gap * 24}">${gapSvg(gap)}</div>`;
} else {
html += `<div class="col col--sep" style="--w:26">${ladderSvg()}</div>`;
}
const segs = song.parts.map((p, pi) => `
<div class="seg" data-part="${pi}" data-motifs="${p.motifIds.join(" ")}">
<span class="seg__name">${esc(p.name)}</span>
<span class="seg__glyphs">${p.motifIds.map((id) => `<span class="sg" data-motif="${id}" title="${esc(motifById.get(id)?.name ?? id)}">${glyph(id)}</span>`).join("")}</span>
</div>`).join("");
html += `
<div class="col col--song" data-song="${song.id}" style="--w:${panelWidth(song.id)}">
<button class="panel" type="button" aria-label="Open track ${song.track}: ${esc(song.title)}" aria-expanded="false">${panelSvg(song.id, song.title)}</button>
<div class="strip" aria-label="Themes in ${esc(song.title)}">${segs}</div>
</div>`;
});
html += `<div class="col col--sep col--end" style="--w:132">${sagaSvg("More to come", 0)}</div>`;
return html;
}
// ---------- shell ----------
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Greek frieze</h1><p>stub</p>`;
app.innerHTML = `
<header class="lip">
<div class="brand">
<p class="brand__mark" aria-hidden="true">EPIC</p>
<div>
<h1>The voyage, painted round a vase</h1>
<p class="brand__hint">Scroll, drag or use the arrow keys to turn it. Click a scene to read the song.</p>
</div>
</div>
<nav class="sagas" aria-label="Turn to a saga">
${[...sagas].sort((a, b) => a.order - b.order).map((s) => `<button type="button" data-goto="${s.id}">${esc(s.name.replace(/^The /, ""))}</button>`).join("")}
</nav>
</header>
<section class="stage" aria-label="Song frieze">
<div class="vase" tabindex="0" aria-label="Frieze of songs, scrolls sideways">
<div class="vase__body">
<div class="band band--lip"></div>
<div class="band band--tongue"></div>
<div class="band band--mid"></div>
<div class="band band--foot"></div>
<div class="row">${columns()}</div>
</div>
</div>
</section>
<section class="register" aria-live="polite" hidden></section>
<nav class="key" aria-label="Follow a theme">
<p class="key__title">Follow a theme</p>
<div class="key__group">
${motifs.filter((m) => m.kind === "motif").map(keyBtn).join("")}
<span class="key__divider" aria-hidden="true"></span>
${motifs.filter((m) => m.kind === "character").map(keyBtn).join("")}
</div>
<p class="key__status" aria-live="polite"></p>
</nav>`;
function keyBtn(m: (typeof motifs)[number]): string {
return `<button type="button" class="key__btn" data-motif="${m.id}" aria-pressed="false" title="${esc(m.description)}">${glyph(m.id)}<span>${esc(m.name)}</span></button>`;
}
const vase = app.querySelector<HTMLElement>(".vase")!;
const body = app.querySelector<HTMLElement>(".vase__body")!;
const register = app.querySelector<HTMLElement>(".register")!;
const status = app.querySelector<HTMLElement>(".key__status")!;
// ---------- sizing: bands are fixed, the figure field takes what is left ----------
const BANDS = {
closed: { lip: 24, tongue: 22, mid: 22, strip: 66, foot: 40 },
open: { lip: 14, tongue: 0, mid: 12, strip: 52, foot: 0 },
};
let lastFig = 0;
function size() {
const b = openId ? BANDS.open : BANDS.closed;
const h = vase.clientHeight;
const fig = Math.max(110, h - b.lip - b.tongue - b.mid - b.strip - b.foot);
if (fig === lastFig) return;
// keep the same spot of the frieze in the middle while it rescales
const first = lastFig === 0 || vase.scrollLeft === 0;
const mid = vase.scrollWidth ? (vase.scrollLeft + vase.clientWidth / 2) / vase.scrollWidth : 0;
lastFig = fig;
for (const [k, v] of Object.entries(b)) body.style.setProperty(`--${k}`, `${v}px`);
body.style.setProperty("--fig", `${fig}px`);
body.style.setProperty("--ph", String(PANEL_H));
const open = openId ? body.querySelector<HTMLElement>(`.col--song[data-song="${openId}"]`) : null;
if (open) centreOn(open, false);
else if (!first) vase.scrollLeft = mid * vase.scrollWidth - vase.clientWidth / 2;
}
new ResizeObserver(size).observe(vase);
size();
// ---------- panning: wheel, drag, keys ----------
vase.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
vase.scrollLeft += e.deltaY;
e.preventDefault();
}
}, { passive: false });
let drag: { x: number; left: number; moved: boolean; id: number } | null = null;
vase.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || e.pointerType === "touch") return;
drag = { x: e.clientX, left: vase.scrollLeft, moved: false, id: e.pointerId };
});
vase.addEventListener("pointermove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (!drag.moved && Math.abs(dx) > 5) {
drag.moved = true;
vase.setPointerCapture(drag.id);
vase.classList.add("is-dragging");
}
if (drag.moved) vase.scrollLeft = drag.left - dx;
});
const endDrag = () => {
if (drag?.moved) {
// swallow the click that ends a drag
addEventListener("click", (ev) => { ev.stopPropagation(); ev.preventDefault(); }, { capture: true, once: true });
setTimeout(() => vase.classList.remove("is-dragging"), 0);
}
drag = null;
};
vase.addEventListener("pointerup", endDrag);
vase.addEventListener("pointercancel", () => { drag = null; vase.classList.remove("is-dragging"); });
vase.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
vase.scrollBy({ left: (e.key === "ArrowRight" ? 1 : -1) * vase.clientWidth * 0.4, behavior: reducedMotion ? "auto" : "smooth" });
e.preventDefault();
}
});
addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Escape" && openId) closeSong(); });
function centreOn(el: HTMLElement, smooth = true) {
vase.scrollTo({ left: el.offsetLeft - (vase.clientWidth - el.offsetWidth) / 2, behavior: smooth && !reducedMotion ? "smooth" : "auto" });
}
// saga jump + active saga
const sagaBtns = [...app.querySelectorAll<HTMLButtonElement>("[data-goto]")];
for (const b of sagaBtns) {
b.addEventListener("click", () => {
const col = body.querySelector<HTMLElement>(`.col--sep[data-saga="${b.dataset.goto}"]`);
if (col) vase.scrollTo({ left: col.offsetLeft - 24, behavior: reducedMotion ? "auto" : "smooth" });
});
}
const sagaCols = [...body.querySelectorAll<HTMLElement>(".col--sep[data-saga]")];
function markSaga() {
const probe = vase.scrollLeft + vase.clientWidth * 0.5;
let current = sagaCols[0]?.dataset.saga;
for (const c of sagaCols) if (c.offsetLeft <= probe) current = c.dataset.saga;
if (vase.scrollLeft + vase.clientWidth >= vase.scrollWidth - 2) current = sagaCols.at(-1)?.dataset.saga;
if (openId) current = songById.get(openId)?.sagaId;
for (const b of sagaBtns) b.setAttribute("aria-current", String(b.dataset.goto === current));
}
vase.addEventListener("scroll", markSaga, { passive: true });
markSaga();
// ---------- opening a song: the red-figure register ----------
body.addEventListener("click", (e) => {
const col = (e.target as Element).closest<HTMLElement>(".col--song");
if (!col || !(e.target as Element).closest(".panel")) return;
const id = col.dataset.song!;
if (openId === id) closeSong();
else openSong(id);
});
function openSong(id: string, smooth = true) {
const song = songById.get(id);
if (!song) return;
openId = id;
document.body.classList.add("is-open");
for (const c of body.querySelectorAll<HTMLElement>(".col--song")) {
const on = c.dataset.song === id;
c.classList.toggle("is-open", on);
c.querySelector(".panel")!.setAttribute("aria-expanded", String(on));
}
renderRegister(song);
register.hidden = false;
size();
const col = body.querySelector<HTMLElement>(`.col--song[data-song="${id}"]`)!;
requestAnimationFrame(() => { centreOn(col, smooth); markSaga(); });
}
function closeSong() {
const was = openId;
openId = null;
document.body.classList.remove("is-open");
register.hidden = true;
register.replaceChildren();
for (const c of body.querySelectorAll<HTMLElement>(".col--song")) {
c.classList.remove("is-open");
c.querySelector(".panel")!.setAttribute("aria-expanded", "false");
}
size();
markSaga();
const col = was ? body.querySelector<HTMLElement>(`.col--song[data-song="${was}"]`) : null;
if (col) requestAnimationFrame(() => { centreOn(col, false); col.querySelector<HTMLElement>(".panel")!.focus(); });
}
function renderRegister(song: Song) {
const saga = sagaOf(song);
const idx = ordered.indexOf(song);
const prev = ordered[idx - 1];
const next = ordered[idx + 1];
const homer = song.odyssey
? `<a href="${esc(song.odyssey.url)}" target="_blank" rel="noopener noreferrer">Odyssey, book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}</a><span class="reg-note">Samuel Butler's translation, Project Gutenberg</span>`
: `<span>No direct counterpart in Homer</span>`;
const parts = song.parts.map((p) => `
<li class="part" data-motifs="${p.motifIds.join(" ")}">
<div class="part__head">
<h4>${esc(p.name)}</h4>
<p class="part__who">Sung by ${esc(list(p.singers))}</p>
</div>
<div class="part__lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
<div class="part__chips">${p.motifIds.map((id) => {
const m = motifById.get(id);
return `<button type="button" class="chip" data-motif="${id}" aria-pressed="${activeMotif === id}">${glyph(id)}<span>${esc(m?.name ?? id)}</span></button>`;
}).join("")}</div>
</li>`).join("");
register.innerHTML = `
<div class="register__inner">
<div class="reg-a">
<div class="reg-art">${panelSvg(song.id, song.title, false)}</div>
<p class="reg-track">Track ${song.track}, ${esc(saga.name)}</p>
<h2 id="reg-title">${esc(song.title)}</h2>
<dl class="reg-meta">
<div><dt>When</dt><dd>${esc(song.storyTime.label)}<span class="reg-note">${yearsAfter(song.storyTime.yearsAfterTroy)}</span></dd></div>
<div><dt>In Homer</dt><dd>${homer}</dd></div>
</dl>
</div>
<div class="reg-b">
<h3>The song, part by part</h3>
<ol class="parts">${parts}</ol>
${song.facts.map((f) => `<p class="reg-fact"><strong>Worth knowing.</strong> ${esc(f)}</p>`).join("")}
</div>
<div class="reg-c">
<h3>Listen</h3>
<div class="slot-audio"></div>
<h3>Watch on YouTube</h3>
<div class="slot-video"></div>
<div class="reg-nav">
<button type="button" data-step="${prev?.id ?? ""}" ${prev ? "" : "disabled"}>Previous scene</button>
<button type="button" data-step="${next?.id ?? ""}" ${next ? "" : "disabled"}>Next scene</button>
</div>
</div>
<button type="button" class="reg-close" aria-label="Close ${esc(song.title)}">Close</button>
</div>`;
register.setAttribute("aria-labelledby", "reg-title");
register.querySelector(".slot-audio")!.append(createAudioSlot(song));
register.querySelector(".slot-video")!.append(createVideoCards(song));
register.querySelector(".reg-close")!.addEventListener("click", closeSong);
for (const b of register.querySelectorAll<HTMLButtonElement>("[data-step]")) {
b.addEventListener("click", () => b.dataset.step && openSong(b.dataset.step));
}
applyMotif();
}
// ---------- motif linking ----------
app.addEventListener("click", (e) => {
const btn = (e.target as Element).closest<HTMLElement>(".key__btn, .chip");
if (!btn) return;
const id = btn.dataset.motif!;
activeMotif = activeMotif === id ? null : id;
applyMotif();
});
function applyMotif() {
const m = activeMotif;
document.body.classList.toggle("has-motif", !!m);
for (const b of app.querySelectorAll<HTMLElement>(".key__btn, .chip")) {
b.setAttribute("aria-pressed", String(b.dataset.motif === m));
}
const hits = m ? new Set(songsWithMotif(m).map((s) => s.id)) : null;
for (const c of body.querySelectorAll<HTMLElement>(".col--song")) {
c.classList.toggle("is-lit", !!hits?.has(c.dataset.song!));
c.classList.toggle("is-dim", !!hits && !hits.has(c.dataset.song!));
}
for (const el of app.querySelectorAll<HTMLElement>(".seg, .part")) {
el.classList.toggle("is-lit", !!m && el.dataset.motifs!.split(" ").includes(m));
}
for (const el of body.querySelectorAll<HTMLElement>(".sg")) el.classList.toggle("is-match", el.dataset.motif === m);
if (!m || !hits) {
status.textContent = "Pick a sign to light every scene where its theme is heard.";
return;
}
const partCount = songs.reduce((n, s) => n + s.parts.filter((p) => p.motifIds.includes(m)).length, 0);
status.textContent = `${motifById.get(m)?.name}: heard in ${hits.size} ${hits.size === 1 ? "song" : "songs"}, ${partCount} ${partCount === 1 ? "part" : "parts"}.`;
}
// ---------- initial state (query params exist for screenshots) ----------
const params = new URLSearchParams(location.search);
const qm = params.get("motif");
if (qm && motifById.has(qm)) activeMotif = qm;
applyMotif();
const qs = params.get("song");
if (qs && songById.has(qs)) openSong(qs, false);
+397 -2
View File
@@ -1,2 +1,397 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "04 · "; }
/* 04 · Greek frieze — an unrolled black-figure vase: clay ground, glaze figures, incised detail. */
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-700.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-600.css";
:root {
color-scheme: dark;
--clay: #c46a33; /* Attic clay */
--clay-hi: #dc8d52; /* clay catching light */
--clay-lo: #9c4c24; /* clay in shadow */
--glaze: #1d140e; /* black gloss: never pure black, it fires brown */
--white: #efe3c8; /* added white */
--red: #8c2d21; /* added purple-red */
--inscr: "Cormorant Garamond", "Palatino Linotype", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
--ease: cubic-bezier(0.22, 0.8, 0.2, 1);
/* painted bands, tiled along the vase */
--meander: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M0 21.6H24M3.6 21.6V2.6H20.6V17.4H8.4V7.2H15.6V12.6' fill='none' stroke='%231d140e' stroke-width='2.6'/%3E%3C/svg%3E");
--meander-inv: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M0 21.6H24M3.6 21.6V2.6H20.6V17.4H8.4V7.2H15.6V12.6' fill='none' stroke='%23c46a33' stroke-width='2.6'/%3E%3C/svg%3E");
--tongues: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='24' viewBox='0 0 16 24'%3E%3Cpath d='M2 0H14V15A6 6 0 0 1 2 15Z' fill='%231d140e'/%3E%3C/svg%3E");
--rays: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='40' viewBox='0 0 30 40'%3E%3Cpath d='M2 40L15 3L28 40Z' fill='%231d140e'/%3E%3C/svg%3E");
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
height: 100dvh;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
background: var(--glaze);
color: var(--white);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--white); outline-offset: 2px; }
#app {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr);
}
/* ---------- lip: title and saga jumps ---------- */
.lip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 0.9rem 2rem 0.8rem;
}
.brand { display: flex; align-items: center; gap: 1.25rem; }
.brand__mark {
margin: 0;
font: 700 2.6rem/1 var(--inscr);
letter-spacing: 0.3em;
margin-right: -0.3em;
color: var(--clay);
}
.lip h1 { margin: 0; font: 600 1.35rem/1.15 var(--inscr); color: var(--white); }
.brand__hint { margin: 0.2rem 0 0; font-size: 0.8rem; color: var(--clay-hi); opacity: 0.85; }
.sagas { display: flex; gap: 0.25rem; }
.sagas button {
padding: 0.35rem 0.8rem 0.45rem;
border: 0;
background: none;
font: 600 1.1rem/1 var(--inscr);
color: var(--clay-hi);
cursor: pointer;
border-bottom: 3px solid transparent;
}
.sagas button:hover { color: var(--white); }
.sagas button[aria-current="true"] { color: var(--white); border-bottom-color: var(--clay); }
/* ---------- the vase ---------- */
.stage { position: relative; min-height: 0; min-width: 0; padding: 0.6rem 0; }
.stage::before, .stage::after {
content: "";
position: absolute;
top: 0.6rem; bottom: 0.6rem;
width: 8%;
z-index: 2;
pointer-events: none;
}
/* the frieze darkens toward its edges, as if it still curved round the pot */
.stage::before { left: 0; background: linear-gradient(90deg, rgba(29, 20, 14, 0.7), rgba(29, 20, 14, 0)); }
.stage::after { right: 0; background: linear-gradient(270deg, rgba(29, 20, 14, 0.7), rgba(29, 20, 14, 0)); }
.vase {
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
background: var(--clay);
cursor: grab;
overscroll-behavior-x: contain;
}
.vase::-webkit-scrollbar { display: none; }
.vase.is-dragging { cursor: grabbing; }
.vase.is-dragging .panel { cursor: grabbing; }
.vase:focus-visible { outline-offset: -4px; }
.vase__body { position: relative; height: 100%; width: max-content; padding: 0 9vw; }
.band { position: absolute; left: 0; right: 0; pointer-events: none; }
.band--lip {
top: 0; height: var(--lip);
background: var(--meander) repeat-x left center / auto 100%;
border-bottom: 2px solid var(--glaze);
}
.band--tongue {
top: var(--lip); height: var(--tongue);
background: var(--tongues) repeat-x left top / auto 100%;
border-bottom: 2px solid var(--glaze);
}
.band--mid {
top: calc(var(--lip) + var(--tongue) + var(--fig)); height: var(--mid);
background: var(--meander) repeat-x left center / auto 100%;
border-top: 2px solid var(--glaze);
border-bottom: 2px solid var(--glaze);
}
.band--foot {
bottom: 0; height: var(--foot);
background: var(--rays) repeat-x left bottom / auto 100%;
border-top: 2px solid var(--glaze);
}
.row { position: relative; display: flex; height: 100%; }
.col {
flex: none;
width: calc(var(--fig) * var(--w) / var(--ph));
padding-top: calc(var(--lip) + var(--tongue));
}
.sep-art { display: block; width: 100%; height: var(--fig); }
.panel {
display: block;
width: 100%;
height: var(--fig);
padding: 0;
border: 0;
background: var(--clay);
cursor: pointer;
transition: background-color 0.25s var(--ease);
--ink: var(--glaze);
--inc: var(--clay);
}
.panel:hover { background: var(--clay-hi); --inc: var(--clay-hi); }
.panel:focus-visible { outline: 3px solid var(--white); outline-offset: -3px; }
.scene { display: block; width: 100%; height: 100%; transition: opacity 0.3s var(--ease); }
/* black-figure painting kit */
.sep-art { --ink: var(--glaze); --inc: var(--clay); }
.ink { fill: var(--ink); }
.clay { fill: var(--inc); }
.inc { fill: none; stroke: var(--inc); stroke-linecap: round; stroke-linejoin: round; }
.limb { fill: none; stroke: var(--ink); stroke-linecap: round; stroke-linejoin: round; }
.limb-edge { fill: none; stroke: var(--inc); stroke-linecap: round; stroke-linejoin: round; }
.oar { stroke: var(--ink); stroke-width: 3.2; stroke-linecap: round; }
.rig { fill: none; stroke: var(--ink); stroke-width: 1.4; }
.wave { fill: none; stroke: var(--ink); stroke-linecap: round; }
.ground { stroke: var(--ink); stroke-width: 3; }
.rule { fill: none; stroke: var(--ink); stroke-width: 2.4; }
.vol { fill: none; stroke: var(--ink); stroke-width: 3; stroke-linecap: round; }
.tally-bars { stroke: var(--ink); stroke-width: 7; stroke-linecap: round; }
.inscr {
font: 700 14.5px var(--inscr);
letter-spacing: 0.22em;
fill: var(--ink);
}
.inscr--light { fill: var(--inc); }
.saga-name { font: 700 15px var(--inscr); letter-spacing: 0.2em; fill: var(--ink); }
.tally { font: 700 13px var(--inscr); letter-spacing: 0.12em; fill: var(--ink); }
/* motif register under the scenes: one segment per part */
.strip {
display: flex;
height: var(--strip);
margin-top: var(--mid);
position: relative;
}
.seg {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
border-left: 2px solid var(--glaze);
color: var(--glaze);
--hole: var(--clay);
transition: background-color 0.25s var(--ease);
}
.seg:first-child { border-left: 0; }
.seg__name { font: italic 500 0.95rem/1 var(--inscr); }
.seg__glyphs { display: flex; gap: 0.3rem; }
.sg { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; }
.glyph { width: 22px; height: 22px; display: block; }
.sg.is-match { background: var(--red); color: var(--white); --hole: var(--red); }
.seg.is-lit { background: var(--white); --hole: var(--white); }
/* motif selected: matching scenes turn white-ground, the rest recede */
.is-lit .panel { background: var(--white); --inc: var(--white); }
.is-dim .scene { opacity: 0.22; }
.is-dim .strip { opacity: 0.35; }
/* opened scene */
.col--song.is-open .panel { box-shadow: inset 0 0 0 4px var(--glaze); }
.col--song.is-open .strip { background: var(--glaze); color: var(--clay-hi); }
.col--song.is-open .seg { color: var(--clay-hi); border-color: var(--clay); --hole: var(--glaze); }
.col--song.is-open .seg.is-lit { background: var(--red); color: var(--white); --hole: var(--red); }
.col--song.is-open .sg.is-match { background: var(--white); color: var(--red); --hole: var(--white); }
/* ---------- red-figure register: the song detail ---------- */
.register {
position: relative;
height: clamp(300px, 50vh, 460px);
background: var(--glaze);
border-top: 3px solid var(--clay);
overflow: hidden;
}
.register[hidden] { display: none; }
.register::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 16px;
background: var(--meander-inv) repeat-x left center / auto 100%;
border-bottom: 1px solid var(--clay-lo);
}
.register__inner {
position: relative;
height: 100%;
overflow-y: auto;
padding: 2rem 2rem 1.5rem;
display: grid;
grid-template-columns: minmax(240px, 0.9fr) minmax(340px, 1.5fr) minmax(240px, 0.85fr);
gap: 2.5rem;
align-items: start;
animation: rise 0.45s var(--ease) both;
}
@keyframes rise { from { opacity: 0; transform: translateY(18px); } }
.reg-art { height: 112px; margin-bottom: 0.6rem; }
.reg-art .scene { width: auto; height: 100%; --ink: var(--clay); --inc: var(--glaze); }
.reg-track { margin: 0; font-size: 0.8rem; color: var(--clay-hi); }
.register h2 { margin: 0.15rem 0 0.9rem; font: 600 2.5rem/1 var(--inscr); color: var(--clay-hi); text-wrap: balance; }
.reg-meta { margin: 0; display: grid; gap: 0.7rem; }
.reg-meta dt { font: italic 500 1rem/1.2 var(--inscr); color: var(--clay); }
.reg-meta dd { margin: 0.1rem 0 0; font-size: 0.9rem; line-height: 1.45; }
.reg-note { display: block; font-size: 0.78rem; color: var(--clay-hi); opacity: 0.8; }
.register a { color: var(--white); text-decoration-color: var(--clay); text-underline-offset: 3px; }
.register a:hover { color: var(--clay-hi); }
.register h3 { margin: 0 0 0.6rem; font: 600 1.2rem/1.2 var(--inscr); color: var(--clay); }
.parts { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.part {
padding: 0.65rem 0.9rem 0.75rem;
border-left: 3px solid var(--clay-lo);
transition: background-color 0.25s var(--ease), border-color 0.25s var(--ease);
}
.part.is-lit { border-left-color: var(--white); background: var(--red); }
.part__head { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; }
.part h4 { margin: 0; font: 600 1.15rem/1.2 var(--inscr); color: var(--clay-hi); }
.part.is-lit h4 { color: var(--white); }
.part__who { margin: 0; font-size: 0.82rem; }
.part__lines { margin: 0.35rem 0 0.5rem; }
.part__lines p { margin: 0; font: italic 500 1.02rem/1.45 var(--inscr); color: var(--white); opacity: 0.8; }
.part__chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.chip {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.2rem 0.65rem 0.2rem 0.3rem;
border: 1px solid var(--clay-lo);
border-radius: 999px;
background: transparent;
font-size: 0.76rem;
color: var(--clay-hi);
cursor: pointer;
--hole: var(--glaze);
}
.chip .glyph { width: 18px; height: 18px; }
.chip:hover { border-color: var(--clay-hi); }
.chip[aria-pressed="true"] { background: var(--white); color: var(--red); border-color: var(--white); --hole: var(--white); }
.reg-fact { margin: 1rem 0 0; font-size: 0.88rem; line-height: 1.5; max-width: 60ch; }
.reg-fact strong { font: 600 1.05rem var(--inscr); color: var(--clay-hi); }
.reg-c .audio-slot {
margin-bottom: 1.2rem;
padding: 0.8rem 0.9rem;
border: 1.5px dashed var(--clay-lo);
border-radius: 0;
font-size: 0.84rem;
color: var(--clay-hi);
opacity: 1;
}
.reg-c .video-cards { display: grid; gap: 0.5rem; }
.reg-c .video-card {
position: relative;
padding: 0.65rem 0.9rem 0.65rem 2.4rem;
border: 0;
border-radius: 0;
background: var(--clay);
color: var(--glaze);
font-size: 0.84rem;
font-weight: 600;
}
.reg-c .video-card::before {
content: "";
position: absolute; left: 0.9rem; top: 50%;
translate: 0 -50%;
border-left: 10px solid var(--glaze);
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
}
.reg-c .video-card:hover { background: var(--clay-hi); opacity: 1; }
.reg-nav { display: flex; gap: 0.5rem; margin-top: 1.2rem; }
.reg-nav button, .reg-close {
padding: 0.4rem 0.8rem;
border: 1px solid var(--clay-lo);
background: transparent;
color: var(--clay-hi);
font: 600 1rem/1.1 var(--inscr);
cursor: pointer;
}
.reg-nav button:hover:not(:disabled), .reg-close:hover { border-color: var(--clay-hi); color: var(--white); }
.reg-nav button:disabled { opacity: 0.35; cursor: default; }
.reg-close { position: absolute; top: 1.6rem; right: 2rem; }
/* ---------- theme key ---------- */
.key {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 0.7rem 2rem;
border-top: 1px solid rgba(196, 106, 51, 0.35);
}
.key__title { margin: 0; flex: none; font: italic 500 1.15rem/1 var(--inscr); color: var(--clay); }
.key__group { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.key__divider { width: 1px; height: 1.6rem; margin: 0 0.4rem; background: var(--clay-lo); }
.key__btn {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.2rem 0.7rem 0.2rem 0.2rem;
border: 0;
border-radius: 999px;
background: transparent;
font-size: 0.8rem;
color: var(--clay-hi);
cursor: pointer;
--hole: var(--clay);
}
.key__btn .glyph {
width: 30px; height: 30px; padding: 4px;
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
}
.key__btn:hover { color: var(--white); }
.key__btn[aria-pressed="true"] { background: var(--red); color: var(--white); --hole: var(--white); }
.key__btn[aria-pressed="true"] .glyph { background: var(--white); color: var(--red); }
.key__status { margin: 0 0 0 auto; font-size: 0.8rem; color: var(--clay-hi); text-align: right; max-width: 32ch; }
/* ---------- fan credits ---------- */
.fan-credits {
position: relative;
z-index: 5;
background: var(--glaze);
color: var(--clay-hi);
opacity: 1;
border-top: 1px solid rgba(196, 106, 51, 0.35);
padding: 0.55rem 1.5rem;
font-size: 0.76rem;
}
@media (max-width: 860px) {
.lip { flex-wrap: wrap; padding: 0.7rem 1rem; }
.brand__mark { font-size: 1.8rem; }
.brand__hint { display: none; }
.key { padding: 0.6rem 1rem; flex-wrap: wrap; gap: 0.5rem; }
.key__title { display: none; }
.key__group { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; }
.key__btn { flex: none; }
.key__status { margin: 0; text-align: left; max-width: none; }
.register { height: 58vh; }
.register__inner { grid-template-columns: 1fr; gap: 1.5rem; padding: 2rem 1rem 1.5rem; }
.reg-close { top: 1.6rem; right: 1rem; }
.vase__body { padding: 0 4vw; }
}
@media (prefers-reduced-motion: reduce) {
.register__inner { animation: none; }
.panel, .scene, .seg, .part { transition: none; }
}