From 3bfd4ab456c95507c6354b78b894ae78dc808fd3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 23 Oct 2026 18:52:28 +0200 Subject: [PATCH] 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 --- src/mockups/04-greek-frieze/figures.ts | 269 +++++++++++++++++ src/mockups/04-greek-frieze/glyphs.ts | 23 ++ src/mockups/04-greek-frieze/main.ts | 326 +++++++++++++++++++- src/mockups/04-greek-frieze/style.css | 399 ++++++++++++++++++++++++- 4 files changed, 1014 insertions(+), 3 deletions(-) create mode 100644 src/mockups/04-greek-frieze/figures.ts create mode 100644 src/mockups/04-greek-frieze/glyphs.ts diff --git a/src/mockups/04-greek-frieze/figures.ts b/src/mockups/04-greek-frieze/figures.ts new file mode 100644 index 0000000..d641c48 --- /dev/null +++ b/src/mockups/04-greek-frieze/figures.ts @@ -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) => `${body}`; +const inc = (d: string, w = 1.6) => ``; +const ink = (d: string) => ``; +// A limb: glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze. +const limb = (d: string, w = 6) => + ``; + +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 += ``; + 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 += ``; + } 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 += ``; + upper += ``; + upper += ``; + } + // 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 += `` + 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 += ``; + 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 += ``; + b += inc("M-6,-150 h28 v22 h-28 Z", 1.4); + for (const wx of [-66, 56]) { + b += ``; + 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 += ``; + } + b += ``; + 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 += ``; + 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 += ``; + 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 ``; +} + +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") + + ``); +} + +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) => ``; + +export const PANELS: Record = { + "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") + + `` + + 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 + ? `${escXml(title.toUpperCase())}` + : ""; + return ``; +} + +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 ``; +} + +function palmette(cx: number, cy: number, flip = false): string { + let leaves = ""; + for (let i = -3; i <= 3; i++) { + leaves += ``; + } + const volutes = ``; + return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `` + volutes); +} + +/** A saga boundary: palmettes framing the saga's name, painted vertically. */ +export function sagaSvg(name: string, gap: number): string { + const tally = gap > 0 + ? `${gap} ${gap === 1 ? "year" : "years"} later` + : ""; + return ``; +} + +/** 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 ``; +} diff --git a/src/mockups/04-greek-frieze/glyphs.ts b/src/mockups/04-greek-frieze/glyphs.ts new file mode 100644 index 0000000..dfc52e7 --- /dev/null +++ b/src/mockups/04-greek-frieze/glyphs.ts @@ -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 = { + // Warrior of the Mind: a crested helmet + warrior: ``, + // Open Arms: a figure welcoming, arms wide + "open-arms": ``, + // Just a Man: a lone figure, arms at rest, on the ground line + "just-a-man": ``, + // Remember Them: a grave stele + remember: ``, + // Odysseus: the bow + odysseus: ``, + // Athena: the owl + athena: ``, + // Polyphemus: the single eye + polyphemus: ``, +}; + +export function glyph(id: string, cls = "glyph"): string { + return ``; +} diff --git a/src/mockups/04-greek-frieze/main.ts b/src/mockups/04-greek-frieze/main.ts index 94abd09..310a1ee 100644 --- a/src/mockups/04-greek-frieze/main.ts +++ b/src/mockups/04-greek-frieze/main.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 += `
${sagaSvg(saga.name, gap)}
`; + } else if (gap > 0) { + html += `
${gapSvg(gap)}
`; + } else { + html += `
${ladderSvg()}
`; + } + const segs = song.parts.map((p, pi) => ` +
+ ${esc(p.name)} + ${p.motifIds.map((id) => `${glyph(id)}`).join("")} +
`).join(""); + html += ` +
+ +
${segs}
+
`; + }); + html += `
${sagaSvg("More to come", 0)}
`; + return html; +} + +// ---------- shell ---------- const app = document.getElementById("app")!; -app.innerHTML = `

Greek frieze

stub

`; +app.innerHTML = ` +
+
+ +
+

The voyage, painted round a vase

+

Scroll, drag or use the arrow keys to turn it. Click a scene to read the song.

+
+
+ +
+
+
+
+
+
+
+
+
${columns()}
+
+
+
+ + `; + +function keyBtn(m: (typeof motifs)[number]): string { + return ``; +} + +const vase = app.querySelector(".vase")!; +const body = app.querySelector(".vase__body")!; +const register = app.querySelector(".register")!; +const status = app.querySelector(".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(`.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("[data-goto]")]; +for (const b of sagaBtns) { + b.addEventListener("click", () => { + const col = body.querySelector(`.col--sep[data-saga="${b.dataset.goto}"]`); + if (col) vase.scrollTo({ left: col.offsetLeft - 24, behavior: reducedMotion ? "auto" : "smooth" }); + }); +} +const sagaCols = [...body.querySelectorAll(".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(".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(".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(`.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(".col--song")) { + c.classList.remove("is-open"); + c.querySelector(".panel")!.setAttribute("aria-expanded", "false"); + } + size(); + markSaga(); + const col = was ? body.querySelector(`.col--song[data-song="${was}"]`) : null; + if (col) requestAnimationFrame(() => { centreOn(col, false); col.querySelector(".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 + ? `Odyssey, book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}Samuel Butler's translation, Project Gutenberg` + : `No direct counterpart in Homer`; + const parts = song.parts.map((p) => ` +
  • +
    +

    ${esc(p.name)}

    +

    Sung by ${esc(list(p.singers))}

    +
    +
    ${p.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    +
    ${p.motifIds.map((id) => { + const m = motifById.get(id); + return ``; + }).join("")}
    +
  • `).join(""); + + register.innerHTML = ` +
    +
    +
    ${panelSvg(song.id, song.title, false)}
    +

    Track ${song.track}, ${esc(saga.name)}

    +

    ${esc(song.title)}

    +
    +
    When
    ${esc(song.storyTime.label)}${yearsAfter(song.storyTime.yearsAfterTroy)}
    +
    In Homer
    ${homer}
    +
    +
    +
    +

    The song, part by part

    +
      ${parts}
    + ${song.facts.map((f) => `

    Worth knowing. ${esc(f)}

    `).join("")} +
    +
    +

    Listen

    +
    +

    Watch on YouTube

    +
    +
    + + +
    +
    + +
    `; + 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("[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(".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(".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(".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(".seg, .part")) { + el.classList.toggle("is-lit", !!m && el.dataset.motifs!.split(" ").includes(m)); + } + for (const el of body.querySelectorAll(".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); diff --git a/src/mockups/04-greek-frieze/style.css b/src/mockups/04-greek-frieze/style.css index d5949f1..b1b7deb 100644 --- a/src/mockups/04-greek-frieze/style.css +++ b/src/mockups/04-greek-frieze/style.css @@ -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; } +}