feat(mockups): 08 sea chart + log line spike; fix registry count test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 02:31:33 +01:00
1 parent cab220d3e2
commit 42b51983c1
5 files changed
+947 -73

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TOD
Install: `bun install --frozen-lockfile`
Dev: `bun run dev` (`PORT` env, default 3000)
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 7"`
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 11"`
Typecheck: `bun run typecheck`
Build: `bun run build` | Run the build: `bun run start`
Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 epic-timeline`
@@ -1,72 +1,69 @@
# 08 Sea chart + log line: status report (paused at usage limit)
# 08 Sea chart + log line: report
## What's done
- Read the round-2 brief, AGENTS.md, DESIGN.md, the round notes, the shared contract, and mockup 01's ink approach. Loaded the `frontend-design` skill.
- Design plan (not built yet):
- **Palette:** Admiralty-chart look. Water `#dfe9e6`, buff land `#e8d8a8`, navy ink `#1e2c3b`, chart magenta `#a3195b` for the course, ship and selection, and paper `#fbfbf7` for the log.
- **Motif colours:** darkened with `color-mix` against the ink, with a thin ink casing so the pale Odysseus colour stays readable.
- **Type:** Cormorant Garamond for titles and place names, with sea areas in italic. Inter for small numbers.
- **Layout at 1440×900:**
- 56px header;
- the chart fills the space below it (world 1600×600);
- a 268px log line with a sticky 200px motif legend on the left;
- the credits footer.
- **Detail panel:** 380px, slides over the chart's right edge.
- **`src/mockups/08-chart-logline/geo.ts` is written and complete (not yet compiled or typechecked).** It contains:
- the chart world size;
- the coastlines (Troy, Cicones coast, the Cyclops' island, Goat Island, a rock), drawn as closed Catmull-Rom paths;
- port positions, label offsets and waypoints per song;
- `buildRoute()`, which builds one smooth course split into port-to-port legs, with the years crossed on each leg;
- log line x positions: `LOG.hop` 156 per song plus `LOG.year` 300 per in-story year, giving a total width `LOG_W` of about 2550px;
- `xToIndex()`, which maps a log x position to a fractional song index for scrubbing;
- `yearsLabel()`, and the `rng()` helper for depth soundings.
- `index.html` is untouched (the stub is fine). `main.ts` and `style.css` are still the stubs.
**Status:** done. Not committed.
## What's left
- [ ] `main.ts` chart SVG:
- land with a hachured coast (stroke the same path at widths 30, 18 and 8 under the land fill);
- graticule, compass rose, soundings;
- saga sea-area names in italic, plus the place names Troy, Island of the Cyclopes and Land of the Cicones;
- the route: dashed in full, plus a solid magenta overlay up to the ship, using `stroke-dasharray` from cumulative leg lengths measured by `getTotalLength` on legs appended to the SVG;
- ports as circles with the track number, title labels from `ports[id].label`;
- "1 year at sea" and "2 years at sea" labels on the crossing legs.
- [ ] Ship marker at a fractional index: `getPointAtLength` on the leg, rotated to the tangent.
- [ ] View sync:
- animated `viewBox` `{cx, cy, z}`;
- while scrubbing the log, z≈1.5 centred on the ship;
- after leaving the log, return to the full view after 600ms;
- when a song is selected, focus the port at x≈36% so the panel doesn't cover it.
- [ ] Log line:
- saga brackets and a year ruler;
- the time axis, with wavy "N years at sea" gap labels;
- song buttons (track, title, story label);
- 7 motif lanes (16px apart) with a casing and a line from the first to the last occurrence;
- per-song part boxes, filled where the motif plays, so motifs read at part level;
- a scrub cursor with a year readout;
- a sticky legend of motif buttons.
- [ ] Clicking a chart port selects the song and smooth-scrolls the log line to its x. Clicking a log entry selects the song and focuses the chart on it.
- [ ] Detail panel:
- title, saga and track, story time, Odyssey link;
- parts with singers and motif chips, with the selected motif highlighted;
- `createAudioSlot`, `createVideoCards`, a close button.
- [ ] Motif selection:
- colour the route legs where both songs carry the motif, and dot them where it skips;
- ring the ports that carry it;
- fade the other log lanes.
- [ ] URL state (`?song`, `?motif`, `?overlay=1`) via `replaceState`.
- [ ] `?overlay=1`:
- dark sea gradient backdrop, with the app as a centred ~1180×740 panel;
- title bar reading "You are here: Survive", with the ship fixed at Survive;
- clicking a port opens a popover with a "Sail here" button that animates the ship there;
- the scrub shows a probe ring instead of moving the ship;
- the credits footer stays visible: fixed, light text on dark.
- [ ] `style.css` (unique). It needs the `@fontsource` imports: `cormorant-garamond/latin-{400,500,600,400-italic,500-italic}.css` and `inter/{400,500}.css`.
- [ ] Build to `<scratchpad>/r2-08-chart-logline`, serve on port 4108, take the 4 screenshots, and look at them. Use `?overlay=1&song=my-goodbye` for the overlay shot so the popover shows.
- [ ] Run `bun test` and `bun run typecheck`, finalise this report, and reply in the brief's format.
## The navigation idea
## Known broken bits
- None at runtime. The page still renders the stub text, because `geo.ts` is not imported anywhere yet.
- The port label offsets in `geo.ts` are first guesses and need tuning against a screenshot.
The screen is split in two:
## Next step to resume
Write `src/mockups/08-chart-logline/main.ts` against `geo.ts`, following the "What's left" list in order, then `style.css`, then build and screenshot.
- **Top two thirds: an Admiralty-style chart that shows *where*.** It has buff land with hachure and shallow-water bands, soundings, a graticule and a compass rose. The course is drawn in chart magenta, and songs are numbered ports.
- **Bottom third: a horizontal log line in the style of 01 that shows *when*.** Gap length tracks in-story years (156px per song plus 300px per year). Years at sea are drawn as a magenta swell with a "1 year at sea" label. The year boundaries are marked "year 0/1/2/3" along the top. Seven motif lanes run underneath.
The two halves are synced:
- Hovering or dragging along the log moves the ship along the course, and the chart zooms to 1.5× around the ship. The cursor shows a readout such as "year 1, Warrior of the Mind" or "year 2, at sea". 600ms after you leave the log, the chart eases back out.
- Clicking a port selects the song and smooth-scrolls the log to it.
- Clicking a log entry selects the song and focuses the chart on that port. The port sits at about 36% of the chart width, so the 380px detail panel doesn't cover it.
**Motifs** are selected from a sticky legend or from the chips in the detail panel.
- Course legs where both songs carry the motif turn solid in the motif's colour. Legs where the motif skips a port are dotted.
- Ports that carry the motif get a ring, and the rest fade.
- The other lanes in the log fade.
- Each lane shows one box per part, filled where the motif plays, so motifs read at part level.
**Overlay** (`?overlay=1`): the same chart and log sit in a compact 1180×740 panel over a dark sea gradient, under a "You are here: Survive" bar.
- Clicking a port opens a popover beside it, with the port, title, the time ahead or back, and a **Sail here** button. The button animates the ship along the course and updates "You are here".
- Scrubbing the log shows a dashed probe ring instead of moving the ship.
- The credits are fixed at the bottom of the viewport.
URL state is kept in `?song`, `?motif` and `?overlay` via `replaceState`.
## What works
- **Where and when are both readable at a glance, with no hunting.** The chart shows all 10 port titles and the saga sea-areas. The log shows every title, story label, saga bracket and year boundary.
- **Scrubbing is the strongest part.** It plays the voyage back like a film strip, and the long swell between Open Arms and Warrior of the Mind physically shows the lost year.
- **Motifs read well on both halves.** For example, Warrior of the Mind is solid only on Warrior→Polyphemus and dotted elsewhere.
- **The overlay proves that one chart serves both uses.** I verified "Sail here" from Survive to My Goodbye end to end.
- **Every sync path was verified in headless Chrome over CDP:**
- log scrub → chart `viewBox` and ship;
- port click → `?song=`, log `scrollLeft`, and the detail panel;
- overlay port → popover → sail.
## What doesn't work (yet)
- **The log is a scroll strip,** so only about 6 of the 10 songs are visible at once. "When" is never in full view the way "where" is. A minimap or a squeeze-to-fit mode would help.
- **Saga bracket names scroll under the sticky legend** and get clipped (for example "…Cyclops Saga" in the overlay). They should stick to the left edge.
- **Labels on steep course legs are hard to read.** The "1 year at sea" label on the chart follows the leg, so it reads at a steep angle.
- **The ship rides offset away from each port's label,** so at Survive it sits on the island's shore band.
- **The overlay has no idea of 3D camera context.** It is a gradient stand-in only.
- **Desktop 1440×900 only.** Nothing is responsive.
- **The port label offsets in `geo.ts` are hand-tuned.** Real data with more songs would need a label-placement pass.
## Screenshots
- `screenshots/08-chart-logline-default.png`
- `screenshots/08-chart-logline-motif.png` (`?motif=warrior`)
- `screenshots/08-chart-logline-song.png` (`?song=polyphemus`)
- `screenshots/08-chart-logline-overlay.png` (`?overlay=1&song=my-goodbye`)
- `screenshots/08-chart-logline-scrub.png` (extra: mid-scrub, chart zoomed onto the ship)
## Checks
- `bun run typecheck`: passes.
- `bun test`: 41 pass and 1 fail. The failure is `registry has 7 mockups` in `tests/server.test.ts`. It already fails at HEAD, because commit 52feed5 grew the registry to 11 without updating the test. That is outside this spike's folder.
- **Build:** a scratch outdir build of the real tree succeeds.
- Midway through, the build failed with "Multiple files share the same output path" because spikes 09, 10 and 11 had identical in-progress CSS.
- While that was happening, I built from a scratch copy, adding a marker rule to their CSS in the copy only.
- That collision has since cleared.
+524 -1
View File
@@ -1,4 +1,527 @@
import { motifs, sagas, songs } from "../../data/sample";
import { sagaOf } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import { H, LOG, LOG_W, W, buildRoute, land, logX, ports, rng, xToIndex, yearsLabel } from "./geo";
mountCredits();
document.getElementById("app")!.textContent = "Sea chart + log line (stub)";
/* ------------------------------------------------------------------ state */
const params = new URLSearchParams(location.search);
const overlay = params.get("overlay") === "1";
const HERE = songs.findIndex((s) => s.id === "survive");
let selected: number = songs.findIndex((s) => s.id === params.get("song"));
let motifId: string | null = motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null;
let shipAt = overlay ? HERE : songs.length - 1; // fractional song index
let probeAt: number | null = null;
if (overlay) document.body.classList.add("is-overlay");
/* ------------------------------------------------------------------ helpers */
const SVG = "http://www.w3.org/2000/svg";
type Attrs = Record<string, string | number | undefined>;
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
const el = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
parent?.append(el);
return el;
}
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
el.append(...kids);
return el;
}
function text(parent: Element, x: number, y: number, str: string, cls: string, anchor = "middle") {
const t = s("text", { x, y, class: cls, "text-anchor": anchor }, parent);
t.textContent = str;
return t;
}
/** Shared motif colours are tuned for dark pages; on the chart they are mixed toward the navy ink. */
function inkOf(m: Motif): string {
const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
const a = hex(m.color), b = hex("#1e2c3b");
const k = m.id === "odysseus" ? 0.55 : 0.28;
return `rgb(${a.map((v, i) => Math.round(v * (1 - k) + b[i]! * k)).join(",")})`;
}
const motifById = new Map(motifs.map((m) => [m.id, m]));
const has = (song: Song, id: string) => song.parts.some((p) => p.motifIds.includes(id));
/* ------------------------------------------------------------------ shell */
const app = document.getElementById("app")!;
const titleBar = h("header", { class: "bar" });
if (overlay) {
titleBar.append(
h("p", { class: "bar__here" }, h("span", { class: "bar__pin", "aria-hidden": "true" }), "You are here: ", h("strong", {}, songs[HERE]!.title)),
h("p", { class: "bar__hint" }, "Pick a port to set a course. Scrub the log to look ahead without sailing."),
);
} else {
titleBar.append(
h("h1", { class: "bar__title" }, "EPIC, charted"),
h("p", { class: "bar__hint" }, "The chart shows where. The log below shows when. Run a finger along the log and the ship follows."),
);
}
const chartWrap = h("section", { class: "chart", "aria-label": "Sea chart of the voyage" });
const logWrap = h("section", { class: "log", "aria-label": "Log line in story time" });
const panel = h("aside", { class: "panel", "aria-live": "polite", hidden: "" });
const pop = h("div", { class: "pop", hidden: "" });
chartWrap.append(panel, pop);
app.append(titleBar, chartWrap, logWrap);
/* ================================================================== CHART */
const svg = s("svg", { class: "chart__svg", role: "img", "aria-label": "Stylised chart: Troy, the land of the Cicones, the island of the Cyclopes and the open sea" }, chartWrap);
const defs = s("defs", {}, svg);
const hatch = s("pattern", { id: "hatch", width: 7, height: 7, patternUnits: "userSpaceOnUse", patternTransform: "rotate(35)" }, defs);
s("line", { x1: 0, y1: 0, x2: 0, y2: 7, class: "hatch" }, hatch);
// Sea, graticule, soundings
s("rect", { x: -400, y: -300, width: W + 800, height: H + 600, class: "sea" }, svg);
const grat = s("g", { class: "grat" }, svg);
for (let x = 0; x <= W; x += 200) s("line", { x1: x, y1: -300, x2: x, y2: H + 300 }, grat);
for (let y = 0; y <= H; y += 200) s("line", { x1: -400, y1: y, x2: W + 400, y2: y }, grat);
const sounds = s("g", { class: "sounds" }, svg);
// Land: shallow-water bands, then the land fill and hachure
const landG = s("g", {}, svg);
const landPaths: SVGPathElement[] = [];
for (const [w, cls] of [[30, "shoal-1"], [16, "shoal-2"], [6, "shoal-3"]] as const) {
for (const d of Object.values(land)) s("path", { d, class: cls, "stroke-width": w }, landG);
}
for (const d of Object.values(land)) {
landPaths.push(s("path", { d, class: "land" }, landG));
s("path", { d, class: "land-hatch" }, landG);
}
// Names: land upright, sea areas in italic (chart convention)
const names = s("g", { class: "names" }, svg);
text(names, 70, 250, "Troy", "place place--big");
text(names, 70, 274, "the burning city", "place-sub");
text(names, 486, 34, "Land of the Cicones", "place");
text(names, 918, 336, "Island of the", "place");
text(names, 918, 358, "Cyclopes", "place");
text(names, 1100, 150, "Goat Island", "place place--small");
text(names, 420, 548, "The Troy Saga", "sea-area");
text(names, 690, 470, "The Cyclops Saga", "sea-area");
text(names, 1290, 200, "The Ocean Saga", "sea-area");
text(names, 1290, 226, "open water, no landfall", "place-sub");
// Compass rose
const rose = s("g", { class: "rose", transform: "translate(1500 92)" }, svg);
s("circle", { r: 46, class: "rose__ring" }, rose);
s("circle", { r: 38, class: "rose__ring rose__ring--in" }, rose);
for (let a = 0; a < 360; a += 10) {
const r1 = a % 90 === 0 ? 30 : 38, rad = (a * Math.PI) / 180;
s("line", { x1: Math.sin(rad) * r1, y1: -Math.cos(rad) * r1, x2: Math.sin(rad) * 46, y2: -Math.cos(rad) * 46, class: "rose__tick" }, rose);
}
s("path", { d: "M0,-44 L7,0 L0,44 L-7,0Z M-44,0 L0,7 L44,0 L0,-7Z", class: "rose__star" }, rose);
s("path", { d: "M0,-44 L7,0 L-7,0Z", class: "rose__north" }, rose);
text(rose, 0, -54, "N", "rose__n");
// Route: one leg per pair of consecutive songs
const route = buildRoute();
const routeG = s("g", { class: "route" }, svg);
const motifG = s("g", { class: "motif-route" }, svg);
const legs = route.map((seg) => {
const base = s("path", { d: seg.d, class: "leg leg--plan" }, routeG);
const sailed = s("path", { d: seg.d, class: "leg leg--sailed" }, routeG);
return { seg, base, sailed, len: 0 };
});
for (const l of legs) {
l.len = l.base.getTotalLength();
if (l.seg.years > 0) {
const p = l.base.getPointAtLength(l.len * 0.5), q = l.base.getPointAtLength(l.len * 0.5 + 4);
let a = Math.atan2(q.y - p.y, q.x - p.x);
if (Math.cos(a) < 0) a += Math.PI; // keep text upright
const nx = Math.sin(a) * 16, ny = -Math.cos(a) * 16; // normal, on the upper side of the text
const lab = text(routeG, p.x + nx, p.y + ny, yearsLabel(l.seg.years), "crossing");
lab.setAttribute("transform", `rotate(${((a * 180) / Math.PI).toFixed(1)} ${p.x + nx} ${p.y + ny})`);
}
}
// Soundings: deterministic, kept off land and off the course
{
const r = rng(8);
let placed = 0;
for (let k = 0; k < 600 && placed < 70; k++) {
const x = r() * W, y = r() * H;
const pt = new DOMPoint(x, y);
if (landPaths.some((p) => p.isPointInFill(pt) || p.isPointInStroke(pt))) continue;
const near = legs.some((l) => { for (let t = 0; t <= 1; t += 0.05) { const q = l.base.getPointAtLength(l.len * t); if (Math.hypot(q.x - x, q.y - y) < 34) return true; } return false; });
if (near) continue;
const ns = Object.values(ports).some((g) => Math.hypot(g.at[0] - x, g.at[1] - y) < 90);
if (ns) continue;
text(sounds, x, y, String(Math.round(8 + r() * 60 * (x / W + 0.3))), "sound");
placed++;
}
}
// Ports
const portG = s("g", { class: "ports" }, svg);
const portEls = songs.map((song, i) => {
const g = ports[song.id]!;
const el = s("g", { class: "port", transform: `translate(${g.at[0]} ${g.at[1]})`, tabindex: 0, role: "button", "aria-label": `${song.track}. ${song.title}` }, portG);
s("circle", { r: 22, class: "port__ring" }, el);
s("circle", { r: 12, class: "port__dot" }, el);
text(el, 0, 4, String(song.track), "port__num");
const [lx, ly, anchor] = g.label;
text(el, lx, ly, song.title, "port__label", anchor);
el.addEventListener("click", (e) => { e.stopPropagation(); onPort(i); });
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPort(i); } });
return el;
});
// Ship and probe
const probe = s("circle", { r: 26, class: "probe", visibility: "hidden" }, svg);
const ship = s("g", { class: "ship" }, svg);
s("path", { d: "M-16,0 L-10,-7 L12,-7 L18,0 L12,7 L-10,7Z", class: "ship__hull" }, ship);
s("path", { d: "M-6,-7 L2,-24 L6,-7Z", class: "ship__sail" }, ship);
function pointAt(f: number): { x: number; y: number; a: number } {
const i = Math.min(Math.floor(f), legs.length - 1);
if (f >= songs.length - 1) { const g = ports[songs[songs.length - 1]!.id]!; const l = legs[legs.length - 1]!; const q = l.base.getPointAtLength(l.len - 1); return { x: g.at[0], y: g.at[1], a: Math.atan2(g.at[1] - q.y, g.at[0] - q.x) }; }
const l = legs[i]!, t = f - i;
const p = l.base.getPointAtLength(l.len * t), q = l.base.getPointAtLength(Math.min(l.len, l.len * t + 2));
return { x: p.x, y: p.y, a: Math.atan2(q.y - p.y, q.x - p.x) };
}
/** The ship rides beside the course, on the side away from each port's label. */
const shipOff = songs.map((x) => {
const [lx, ly] = ports[x.id]!.label;
const n = Math.hypot(lx, ly) || 1;
return [(-lx / n) * 30, (-ly / n) * 30 - 6] as const;
});
function drawShip() {
const p = pointAt(shipAt);
const i = Math.min(Math.floor(shipAt), songs.length - 1), t = shipAt - i;
const a = shipOff[i]!, b = shipOff[Math.min(i + 1, songs.length - 1)]!;
const ox = a[0] + (b[0] - a[0]) * t, oy = a[1] + (b[1] - a[1]) * t;
ship.setAttribute("transform", `translate(${(p.x + ox).toFixed(1)} ${(p.y + oy).toFixed(1)})${Math.cos(p.a) < 0 ? " scale(-1 1)" : ""}`);
legs.forEach((l, i) => {
const done = shipAt >= i + 1 ? 1 : shipAt <= i ? 0 : shipAt - i;
l.sailed.setAttribute("stroke-dasharray", `${l.len * done} ${l.len + 10}`);
});
if (probeAt === null) probe.setAttribute("visibility", "hidden");
else { const q = pointAt(probeAt); probe.setAttribute("cx", String(q.x)); probe.setAttribute("cy", String(q.y)); probe.setAttribute("visibility", "visible"); }
}
/* ------------------------------------------------------------------ view (animated viewBox) */
const view = { cx: W / 2, cy: H / 2, z: 1 };
let target = { ...view };
let raf = 0;
function applyView() {
const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1;
const scale = Math.min(cw / W, ch / H) * view.z;
const vw = cw / scale, vh = ch / scale;
svg.setAttribute("viewBox", `${(view.cx - vw / 2).toFixed(1)} ${(view.cy - vh / 2).toFixed(1)} ${vw.toFixed(1)} ${vh.toFixed(1)}`);
svg.style.setProperty("--z", String(view.z));
}
function tick() {
const k = 0.16;
view.cx += (target.cx - view.cx) * k;
view.cy += (target.cy - view.cy) * k;
view.z += (target.z - view.z) * k;
applyView();
if (Math.abs(target.cx - view.cx) + Math.abs(target.cy - view.cy) + Math.abs(target.z - view.z) * 100 > 0.3) raf = requestAnimationFrame(tick);
else { Object.assign(view, target); applyView(); raf = 0; }
}
function goTo(t: { cx: number; cy: number; z: number }, instant = false) {
target = t;
if (instant || matchMedia("(prefers-reduced-motion: reduce)").matches) { Object.assign(view, t); applyView(); return; }
if (!raf) raf = requestAnimationFrame(tick);
}
const fullView = () => ({ cx: W / 2, cy: H / 2, z: 1 });
/** Centre on a world point, shifted so it lands at `frac` of the chart width. */
function focusOn(x: number, y: number, z: number, frac = 0.5) {
const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1;
const vw = cw / (Math.min(cw / W, ch / H) * z);
return { cx: x + (0.5 - frac) * vw, cy: y, z };
}
function restView() {
if (selected >= 0 && !overlay) { const g = ports[songs[selected]!.id]!; return focusOn(g.at[0], g.at[1], 1.55, 0.36); }
return fullView();
}
new ResizeObserver(() => applyView()).observe(svg);
/* ================================================================== LOG LINE */
const AXIS_Y = 92, LANE_Y = 140, LANE = 16;
const logTrack = h("div", { class: "log__track" });
const legend = h("div", { class: "log__legend" });
const logBody = h("div", { class: "log__body", style: `width:${LOG_W}px` });
logTrack.append(legend, logBody);
logWrap.append(logTrack);
const logSvg = s("svg", { class: "log__svg", width: LOG_W, height: LANE_Y + motifs.length * LANE }, logBody);
// Saga brackets
for (const saga of sagas) {
const idx = songs.map((x, i) => (x.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const x0 = logX[idx[0]!]! - 62, x1 = logX[idx[idx.length - 1]!]! + 62;
s("path", { d: `M${x0},24 V18 H${x1} V24`, class: "bracket" }, logSvg);
const lab = text(logSvg, x0 + 4, 12, saga.name, "bracket__name", "start");
lab.setAttribute("dy", "0");
}
// Axis: straight within a year, a long swell across the years at sea
for (let i = 0; i < songs.length - 1; i++) {
const a = logX[i]!, b = logX[i + 1]!, gap = route[i]!.years;
if (gap <= 0) { s("line", { x1: a, y1: AXIS_Y, x2: b, y2: AXIS_Y, class: "axis" }, logSvg); continue; }
const g0 = a + LOG.hop / 2, g1 = b - LOG.hop / 2;
let d = `M${a},${AXIS_Y} H${g0}`;
const waves = Math.round((g1 - g0) / 30);
for (let k = 0; k < waves; k++) { const x = g0 + ((g1 - g0) * (k + 0.5)) / waves, x2 = g0 + ((g1 - g0) * (k + 1)) / waves; d += ` Q${x},${AXIS_Y + (k % 2 ? 5 : -5)} ${x2},${AXIS_Y}`; }
d += ` H${b}`;
s("path", { d, class: "axis axis--sea" }, logSvg);
text(logSvg, (g0 + g1) / 2, AXIS_Y - 14, yearsLabel(gap), "gap-label");
for (let k = 1; k <= gap; k++) {
const x = g0 + ((g1 - g0) * k) / gap;
s("line", { x1: x, y1: 30, x2: x, y2: AXIS_Y + 6, class: "year-tick" }, logSvg);
text(logSvg, x - 4, 38, `year ${songs[i]!.storyTime.yearsAfterTroy + k}`, "year-label", "end");
}
}
s("line", { x1: logX[0]! - 50, y1: AXIS_Y, x2: logX[0]!, y2: AXIS_Y, class: "axis" }, logSvg);
s("line", { x1: logX[logX.length - 1]!, y1: AXIS_Y, x2: LOG_W - 20, y2: AXIS_Y, class: "axis axis--fade" }, logSvg);
text(logSvg, logX[0]! - 56, 38, "year 0", "year-label", "start");
// Motif lanes with part boxes
const laneEls = new Map<string, SVGGElement>();
motifs.forEach((m, mi) => {
const y = LANE_Y + mi * LANE;
const g = s("g", { class: "lane", "data-motif": m.id, style: `--c:${inkOf(m)}` }, logSvg);
laneEls.set(m.id, g);
const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0);
if (occ.length) {
s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__case" }, g);
s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__line" }, g);
}
for (const i of occ) {
const parts = songs[i]!.parts, bw = 8, gap = 2, tot = parts.length * bw + (parts.length - 1) * gap;
parts.forEach((p, k) => {
s("rect", { x: logX[i]! - tot / 2 + k * (bw + gap), y: y - 4, width: bw, height: 8, class: p.motifIds.includes(m.id) ? "box box--on" : "box" }, g);
});
}
});
// Song entries (HTML, so titles can wrap)
const entryEls = songs.map((song, i) => {
const b = h("button", { type: "button", class: "entry", style: `left:${logX[i]}px` },
h("span", { class: "entry__num" }, String(song.track)),
h("span", { class: "entry__title" }, song.title),
);
b.addEventListener("click", () => select(i, "log"));
logBody.append(b, h("span", { class: "entry__when", style: `left:${logX[i]}px` }, song.storyTime.label));
s("circle", { cx: logX[i]!, cy: AXIS_Y, r: 4.5, class: "stop", "data-i": i }, logSvg);
return b;
});
// Scrub cursor
const cursor = h("div", { class: "cursor", hidden: "" }, h("span", { class: "cursor__read" }));
logBody.append(cursor);
// Legend
legend.append(h("p", { class: "legend__head" }, "Motifs, sung where filled"));
const legendBtns = new Map<string, HTMLButtonElement>();
for (const m of motifs) {
const b = h("button", { type: "button", class: "legend__btn", style: `--c:${inkOf(m)}`, "aria-pressed": "false" },
h("span", { class: "legend__sw", "aria-hidden": "true" }), m.name);
b.addEventListener("click", () => setMotif(motifId === m.id ? null : m.id));
legend.append(b);
legendBtns.set(m.id, b);
}
/* ------------------------------------------------------------------ scrubbing */
let leaveTimer = 0;
let lastClientX: number | null = null;
function scrubAt(clientX: number) {
const r = logBody.getBoundingClientRect();
const x = Math.max(logX[0]!, Math.min(logX[logX.length - 1]!, clientX - r.left));
const f = xToIndex(x);
cursor.hidden = false;
cursor.style.left = `${x}px`;
const i = Math.round(f);
const atPort = Math.abs(f - i) < 0.12;
const yrs = atPort ? songs[i]!.storyTime.yearsAfterTroy : Math.floor(interpYears(f));
(cursor.firstChild as HTMLElement).textContent = atPort ? `year ${yrs}, ${songs[i]!.title}` : `year ${yrs}, at sea`;
if (overlay) probeAt = f; else shipAt = f;
drawShip();
const p = pointAt(f);
goTo(focusOn(p.x, p.y, 1.5, selected >= 0 && !overlay ? 0.36 : 0.5));
}
function interpYears(f: number) {
const i = Math.floor(f), a = songs[i]!, b = songs[Math.min(i + 1, songs.length - 1)]!;
return a.storyTime.yearsAfterTroy + (b.storyTime.yearsAfterTroy - a.storyTime.yearsAfterTroy) * (f - i);
}
logBody.addEventListener("pointermove", (e) => { clearTimeout(leaveTimer); lastClientX = e.clientX; scrubAt(e.clientX); });
logWrap.addEventListener("scroll", () => { if (lastClientX !== null) scrubAt(lastClientX); }, { passive: true });
logBody.addEventListener("pointerleave", () => {
lastClientX = null;
leaveTimer = window.setTimeout(() => {
cursor.hidden = true;
probeAt = null;
drawShip();
goTo(restView());
}, 600);
});
/* ================================================================== SELECTION */
function scrollLogTo(i: number, instant = false) {
const visible = logWrap.clientWidth - legend.offsetWidth;
const still = instant || matchMedia("(prefers-reduced-motion: reduce)").matches;
logWrap.scrollTo({ left: Math.max(0, logX[i]! - visible / 2), behavior: still ? "auto" : "smooth" });
}
function onPort(i: number) {
if (overlay) { showPop(i); scrollLogTo(i); return; }
select(i, "chart");
}
function select(i: number, from: "chart" | "log" | "init") {
selected = i;
if (overlay) { showPop(i); syncUrl(); return; }
shipAt = i;
drawShip();
renderPanel();
if (from !== "log") scrollLogTo(i, from === "init");
goTo(restView(), from === "init");
syncUrl();
paint();
}
function close() {
selected = -1;
panel.hidden = true;
pop.hidden = true;
goTo(restView());
syncUrl();
paint();
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
chartWrap.addEventListener("click", (e) => { if (e.target === svg || (e.target as Element).classList?.contains("sea")) { if (overlay) { pop.hidden = true; selected = -1; syncUrl(); paint(); } } });
function renderPanel() {
const song = songs[selected];
if (!song) { panel.hidden = true; return; }
const closeBtn = h("button", { type: "button", class: "panel__close", "aria-label": "Close notes" }, "Close");
closeBtn.addEventListener("click", close);
const odyssey = song.odyssey
? h("a", { href: song.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`)
: h("span", { class: "panel__none" }, "No counterpart in Homer");
const parts = h("ol", { class: "parts" });
for (const p of song.parts) {
const chips = h("span", { class: "chips" });
for (const id of p.motifIds) {
const m = motifById.get(id)!;
const c = h("button", { type: "button", class: `chip${id === motifId ? " is-on" : ""}`, style: `--c:${inkOf(m)}` }, m.name);
c.addEventListener("click", () => setMotif(motifId === id ? null : id));
chips.append(c);
}
parts.append(h("li", {}, h("span", { class: "parts__name" }, p.name), h("span", { class: "parts__who" }, p.singers.join(" and ")), chips));
}
panel.replaceChildren(
closeBtn,
h("p", { class: "panel__kicker" }, `${sagaOf(song).name}, track ${song.track}`),
h("h2", { class: "panel__title" }, song.title),
h("p", { class: "panel__when" }, `${song.storyTime.label}, year ${song.storyTime.yearsAfterTroy} after Troy`),
h("p", { class: "panel__ody" }, odyssey),
parts,
createAudioSlot(song),
createVideoCards(song),
);
panel.hidden = false;
}
/* ------------------------------------------------------------------ overlay popover */
function showPop(i: number) {
selected = i;
const song = songs[i]!;
const g = ports[song.id]!;
const isHere = Math.round(shipAt) === i && Math.abs(shipAt - i) < 0.01;
const sail = h("button", { type: "button", class: "pop__sail" }, isHere ? "You are moored here" : "Sail here");
if (isHere) sail.setAttribute("disabled", "");
sail.addEventListener("click", () => sailTo(i));
const yrs = song.storyTime.yearsAfterTroy - songs[Math.round(shipAt)]!.storyTime.yearsAfterTroy;
pop.replaceChildren(
h("p", { class: "pop__num" }, `Port ${song.track}`),
h("p", { class: "pop__title" }, song.title),
h("p", { class: "pop__when" }, song.storyTime.label + (yrs ? `, ${Math.abs(yrs)} ${Math.abs(yrs) === 1 ? "year" : "years"} ${yrs > 0 ? "ahead" : "back"}` : "")),
sail,
);
pop.hidden = false;
placePop(g.at[0], g.at[1]);
syncUrl();
paint();
}
function placePop(x: number, y: number) {
const m = svg.getScreenCTM();
if (!m) return;
const p = new DOMPoint(x, y).matrixTransform(m);
const r = chartWrap.getBoundingClientRect();
pop.style.left = `${p.x - r.left}px`;
pop.style.top = `${p.y - r.top}px`;
}
function sailTo(i: number) {
const from = shipAt, t0 = performance.now(), dur = 900 + Math.abs(i - from) * 260;
pop.hidden = true;
const step = (now: number) => {
const t = Math.min(1, (now - t0) / dur), e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
shipAt = from + (i - from) * e;
drawShip();
if (t < 1) requestAnimationFrame(step);
else { const here = titleBar.querySelector("strong"); if (here) here.textContent = songs[i]!.title; selected = -1; paint(); syncUrl(); }
};
requestAnimationFrame(step);
}
/* ================================================================== MOTIF */
function setMotif(id: string | null) {
motifId = id;
syncUrl();
paint();
if (selected >= 0 && !overlay) renderPanel();
}
function paint() {
document.body.classList.toggle("has-motif", !!motifId);
// Chart: course coloured where the motif carries on, dotted where it skips a port
motifG.replaceChildren();
const m = motifId ? motifById.get(motifId) : undefined;
if (m) {
const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0);
const c = inkOf(m);
for (let i = occ[0]!; i < occ[occ.length - 1]!; i++) {
const both = has(songs[i]!, m.id) && has(songs[i + 1]!, m.id);
s("path", { d: legs[i]!.seg.d, class: both ? "mleg" : "mleg mleg--skip", style: `--c:${c}` }, motifG);
}
}
portEls.forEach((el, i) => {
el.classList.toggle("is-motif", !!m && has(songs[i]!, m.id));
el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id));
el.classList.toggle("is-selected", i === selected);
if (m) el.style.setProperty("--c", inkOf(m)); else el.style.removeProperty("--c");
});
entryEls.forEach((el, i) => {
el.classList.toggle("is-selected", i === selected);
el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id));
el.setAttribute("aria-pressed", String(i === selected));
});
for (const [id, g] of laneEls) g.classList.toggle("is-faded", !!m && id !== m.id);
for (const [id, b] of legendBtns) { b.setAttribute("aria-pressed", String(id === motifId)); b.classList.toggle("is-faded", !!m && id !== m.id); }
}
function syncUrl() {
const p = new URLSearchParams();
if (overlay) p.set("overlay", "1");
if (selected >= 0) p.set("song", songs[selected]!.id);
if (motifId) p.set("motif", motifId);
const q = p.toString();
history.replaceState(null, "", q ? `?${q}` : location.pathname);
}
/* ------------------------------------------------------------------ boot */
applyView();
drawShip();
paint();
if (selected >= 0) requestAnimationFrame(() => select(selected, "init"));
if (overlay) requestAnimationFrame(() => scrollLogTo(selected >= 0 ? selected : HERE, true));
+356 -2
View File
@@ -1,2 +1,356 @@
/* 08-chart-logline stub */
body { margin: 0; }
/* 08 · Sea chart + log line: an Admiralty chart above, a ship's log in story time below. */
@import "@fontsource/cormorant-garamond/latin-400.css";
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
:root {
color-scheme: light;
--water: #dfe9e6;
--shoal: #c9dcd8;
--shoal-deep: #b5cfcc;
--land: #e8d8a8;
--ink: #1e2c3b;
--ink-soft: #4d5b69;
--ink-faint: #8794a0;
--course: #a3195b;
--paper: #fbfbf7;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
--log-h: 268px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
display: flex;
flex-direction: column;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
overflow: hidden;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--course); outline-offset: 2px; }
#app {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: 56px minmax(0, 1fr) var(--log-h);
}
/* ------------------------------------------------------------------ title bar */
.bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 2rem;
padding: 0 28px;
border-bottom: 1.5px solid var(--ink);
background: var(--paper);
}
.bar__title { margin: 0; align-self: center; font-weight: 500; font-size: 28px; letter-spacing: 0.01em; }
.bar__hint { margin: 0; align-self: center; font-style: italic; font-size: 17px; color: var(--ink-soft); }
/* ------------------------------------------------------------------ chart */
.chart { position: relative; overflow: hidden; background: var(--water); }
.chart__svg { display: block; width: 100%; height: 100%; }
.sea { fill: var(--water); }
.grat line { stroke: var(--ink); stroke-opacity: 0.08; stroke-width: 1; vector-effect: non-scaling-stroke; }
.sound { font: italic 400 11px var(--serif); fill: var(--ink-faint); text-anchor: middle; }
.hatch { stroke: #b49a5c; stroke-width: 1.2; }
.shoal-1, .shoal-2, .shoal-3 { fill: none; stroke-linejoin: round; }
.shoal-1 { stroke: var(--shoal); }
.shoal-2 { stroke: var(--shoal-deep); }
.shoal-3 { stroke: #9fbfbd; }
.land { fill: var(--land); stroke: var(--ink); stroke-width: 1.3; }
.land-hatch { fill: url(#hatch); opacity: 0.35; pointer-events: none; }
.names text { pointer-events: none; }
.place { font: 500 17px var(--serif); fill: var(--ink); letter-spacing: 0.06em; }
.place--big { font-size: 30px; letter-spacing: 0.14em; }
.place--small { font-size: 13px; }
.place-sub { font: italic 400 13px var(--serif); fill: var(--ink-soft); }
.sea-area { font: italic 400 30px var(--serif); fill: #3e6378; letter-spacing: 0.12em; }
.rose__ring { fill: none; stroke: var(--course); stroke-width: 1; }
.rose__ring--in { stroke-opacity: 0.5; }
.rose__tick { stroke: var(--course); stroke-width: 0.8; }
.rose__star { fill: var(--paper); stroke: var(--course); stroke-width: 1; }
.rose__north { fill: var(--course); }
.rose__n { font: 600 16px var(--serif); fill: var(--course); }
.leg { fill: none; stroke-linecap: round; }
.leg--plan { stroke: var(--course); stroke-width: 2; stroke-dasharray: 9 7; opacity: 0.55; }
.leg--sailed { stroke: var(--course); stroke-width: 3; }
.crossing { font: italic 500 17px var(--serif); fill: var(--course); }
.mleg { fill: none; stroke: var(--c); stroke-width: 9; stroke-linecap: round; opacity: 0.85; }
.mleg--skip { stroke-width: 4; stroke-dasharray: 1 9; opacity: 1; }
.has-motif .leg--sailed, .has-motif .leg--plan { opacity: 0.18; }
.has-motif .crossing { opacity: 0.45; }
.port { cursor: pointer; outline: none; }
.port__ring { fill: none; stroke: transparent; stroke-width: 3; }
.port__dot { fill: var(--paper); stroke: var(--ink); stroke-width: 2; }
.port__num { font: 500 12px var(--sans); fill: var(--ink); text-anchor: middle; pointer-events: none; }
.port__label {
font: 600 19px var(--serif);
fill: var(--ink);
paint-order: stroke;
stroke: var(--water);
stroke-width: 5px;
stroke-linejoin: round;
}
.port:hover .port__dot, .port:focus-visible .port__dot { fill: #fff3d6; }
.port:focus-visible .port__ring { stroke: var(--course); stroke-dasharray: 3 3; }
.port.is-selected .port__dot { fill: var(--course); stroke: var(--course); }
.port.is-selected .port__num { fill: var(--paper); }
.port.is-motif .port__ring { stroke: var(--c); }
.port.is-dim { opacity: 0.38; }
.ship__hull { fill: var(--ink); }
.ship__sail { fill: var(--paper); stroke: var(--ink); stroke-width: 1.4; }
.probe { fill: none; stroke: var(--course); stroke-width: 2.5; stroke-dasharray: 4 4; }
/* ------------------------------------------------------------------ detail panel */
.panel {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
width: 380px;
overflow: auto;
padding: 22px 24px 24px;
background: var(--paper);
border: 1.5px solid var(--ink);
box-shadow: 6px 6px 0 rgb(30 44 59 / 0.12);
font-family: var(--serif);
}
.panel[hidden] { display: none; }
.panel__close {
float: right;
padding: 4px 10px;
border: 1px solid var(--ink-faint);
background: none;
font: 400 12px var(--sans);
cursor: pointer;
}
.panel__kicker { margin: 0; font-style: italic; font-size: 16px; color: var(--ink-soft); }
.panel__title { margin: 4px 0 6px; font-weight: 500; font-size: 36px; line-height: 1.05; }
.panel__when { margin: 0 0 4px; font-size: 18px; }
.panel__ody { margin: 0 0 16px; font-size: 16px; }
.panel__ody a { color: var(--course); }
.panel__none { color: var(--ink-faint); font-style: italic; }
.parts { list-style: none; margin: 0 0 16px; padding: 0; border-top: 1px solid rgb(30 44 59 / 0.2); }
.parts li { display: grid; grid-template-columns: 70px 1fr; gap: 2px 10px; padding: 8px 0; border-bottom: 1px solid rgb(30 44 59 / 0.2); }
.parts__name { font: 500 12px var(--sans); color: var(--ink-soft); padding-top: 3px; }
.parts__who { font-size: 18px; }
.chips { grid-column: 2; display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
padding: 1px 8px 2px;
border: 1px solid var(--c);
border-radius: 10px;
background: none;
color: var(--c);
font: 400 12px var(--sans);
cursor: pointer;
}
.chip.is-on { background: var(--c); color: var(--paper); }
.panel .audio-slot { margin-bottom: 12px; font: 400 13px var(--sans); color: var(--ink-soft); border-radius: 0; }
.panel .video-card { border-radius: 0; font: 400 13px var(--sans); color: var(--ink); }
/* ------------------------------------------------------------------ log line */
.log {
overflow-x: auto;
overflow-y: hidden;
background: var(--paper);
border-top: 1.5px solid var(--ink);
scrollbar-width: thin;
scrollbar-color: var(--ink-faint) transparent;
}
.log__track { display: flex; width: max-content; height: 100%; }
.log__legend {
position: sticky;
left: 0;
z-index: 3;
flex: 0 0 200px;
padding: 0 14px 0 24px;
padding-top: 109px;
background: linear-gradient(90deg, var(--paper) 88%, rgb(251 251 247 / 0));
}
.legend__head { margin: 0 0 7px; font: italic 400 14px var(--serif); color: var(--ink-soft); }
.legend__btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 16px;
padding: 0;
border: 0;
background: none;
font: 400 11.5px var(--sans);
color: var(--ink);
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.legend__sw { width: 22px; height: 3px; background: var(--c); box-shadow: 0 0 0 1px var(--ink); flex: none; }
.legend__btn[aria-pressed="true"] { font-weight: 500; color: var(--c); }
.legend__btn[aria-pressed="true"] .legend__sw { height: 6px; }
.legend__btn.is-faded { opacity: 0.4; }
.legend__btn:hover { color: var(--c); }
.log__body { position: relative; flex: none; height: 100%; cursor: ew-resize; }
.log__svg { position: absolute; top: 0; left: 0; overflow: visible; }
.bracket { fill: none; stroke: var(--ink); stroke-width: 1; }
.bracket__name { font: italic 500 15px var(--serif); fill: var(--ink); }
.axis { fill: none; stroke: var(--ink); stroke-width: 1.5; }
.axis--sea { stroke: var(--course); stroke-width: 1.5; }
.axis--fade { stroke-dasharray: 2 5; }
.gap-label { font: italic 500 17px var(--serif); fill: var(--course); text-anchor: middle; }
.year-tick { stroke: var(--course); stroke-width: 1; stroke-dasharray: 2 3; stroke-opacity: 0.6; }
.year-label { font: 500 11px var(--sans); fill: var(--course); }
.stop { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
.lane__case { stroke: var(--ink); stroke-width: 4; stroke-opacity: 0.7; }
.lane__line { stroke: var(--c); stroke-width: 2.4; }
.box { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
.box--on { fill: var(--c); }
.lane { transition: opacity 0.2s; }
.lane.is-faded { opacity: 0.14; }
.entry {
position: absolute;
bottom: calc(100% - 84px);
transform: translateX(-50%);
width: 140px;
padding: 0;
border: 0;
background: none;
text-align: center;
cursor: pointer;
line-height: 1;
}
.entry__num { display: block; font: 400 10.5px var(--sans); color: var(--ink-faint); margin-bottom: 3px; }
.entry__title { font-weight: 600; font-size: 18px; line-height: 1.02; }
.entry:hover .entry__title { color: var(--course); }
.entry.is-selected .entry__title { color: var(--course); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.entry.is-dim { opacity: 0.35; }
.entry__when {
position: absolute;
top: 101px;
transform: translateX(-50%);
width: 140px;
font-style: italic;
font-size: 13px;
line-height: 1.05;
color: var(--ink-soft);
text-align: center;
pointer-events: none;
}
.cursor { position: absolute; top: 26px; bottom: 6px; width: 0; border-left: 1.5px solid var(--course); pointer-events: none; z-index: 2; }
.cursor[hidden] { display: none; }
.cursor__read {
position: absolute;
top: -2px;
left: 6px;
padding: 1px 6px;
background: var(--course);
color: var(--paper);
font: 500 11px var(--sans);
white-space: nowrap;
}
/* ------------------------------------------------------------------ credits */
.fan-credits { flex: none; padding: 10px 28px; border-top: 1px solid rgb(30 44 59 / 0.2); background: var(--paper); font-style: italic; font-size: 13.5px; opacity: 1; color: var(--ink-soft); text-align: left; }
/* ================================================================== in-3D overlay state */
body.is-overlay {
background:
radial-gradient(ellipse 70% 50% at 50% 115%, rgb(120 160 170 / 0.35), transparent 70%),
linear-gradient(180deg, #0f2231 0%, #183748 55%, #0d1c28 100%);
align-items: center;
justify-content: center;
}
body.is-overlay::before {
content: "";
position: fixed;
inset: 0;
background: rgb(5 12 20 / 0.45);
pointer-events: none;
}
.is-overlay #app {
position: relative;
flex: none;
width: 1180px;
height: 740px;
margin-bottom: 40px;
grid-template-rows: 46px minmax(0, 1fr) var(--log-h);
border: 1.5px solid #e8d8a8;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.55);
}
.is-overlay .bar { background: var(--ink); color: var(--paper); border-bottom: 0; }
.is-overlay .bar__here { margin: 0; align-self: center; font-size: 19px; display: flex; align-items: center; gap: 6px; }
.is-overlay .bar__here strong { font-weight: 600; color: #f3c9dd; }
.bar__pin { width: 9px; height: 9px; border-radius: 50%; background: #e5579a; box-shadow: 0 0 0 4px rgb(229 87 154 / 0.3); margin-right: 4px; }
.is-overlay .bar__hint { color: #b9c4cc; font-size: 15px; }
.is-overlay .fan-credits {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: rgb(5 12 20 / 0.6);
border-top: 1px solid rgb(232 216 168 / 0.25);
color: #e6e1d2;
text-align: center;
z-index: 10;
}
.pop {
position: absolute;
z-index: 4;
transform: translate(30px, -50%);
min-width: 200px;
padding: 10px 14px 12px;
background: var(--paper);
border: 1.5px solid var(--ink);
box-shadow: 4px 4px 0 rgb(30 44 59 / 0.18);
text-align: center;
}
.pop[hidden] { display: none; }
.pop::after {
content: "";
position: absolute;
left: -8px;
top: 50%;
width: 12px;
height: 12px;
background: var(--paper);
border-left: 1.5px solid var(--ink);
border-bottom: 1.5px solid var(--ink);
transform: translateY(-50%) rotate(45deg);
}
.pop p { margin: 0; }
.pop__num { font: 400 11px var(--sans); color: var(--ink-faint); }
.pop__title { font-weight: 600; font-size: 22px; }
.pop__when { font-style: italic; font-size: 14px; color: var(--ink-soft); margin-bottom: 8px !important; }
.pop__sail {
padding: 5px 16px;
border: 0;
background: var(--course);
color: var(--paper);
font: 500 13px var(--sans);
cursor: pointer;
}
.pop__sail:disabled { background: var(--ink-faint); cursor: default; }
@media (prefers-reduced-motion: reduce) {
.lane { transition: none; }
}
+1 -1
View File
@@ -8,7 +8,7 @@ let base: string;
beforeAll(() => { server = startServer(0); base = `http://localhost:${server.port}`; });
afterAll(() => server.stop(true));
test("registry has 7 mockups", () => expect(mockups).toHaveLength(7));
test("registry has 11 mockups", () => expect(mockups).toHaveLength(11));
test("index links every mockup", async () => {
const res = await fetch(base + "/");