diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..20034af
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.git
+.superpowers
+.worktrees
+screenshots
+.env*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..39bab0e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.env*
+CLAUDE.local.md
+.worktrees/
+screenshots/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4885414
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,14 @@
+FROM oven/bun:1.3 AS build
+WORKDIR /app
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile
+COPY . .
+RUN bun run build
+
+FROM oven/bun:1.3
+# Bun resolves bundled HTML assets relative to the cwd, so run from dist/
+WORKDIR /app/dist
+COPY --from=build /app/dist ./
+ENV PORT=3000
+EXPOSE 3000
+CMD ["bun", "server.js"]
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..0963b75
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,109 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "epic_the_musical_timeline",
+ "dependencies": {
+ "@fontsource/cormorant-garamond": "^5.3.0",
+ "@fontsource/inter": "^5.3.0",
+ "three": "^0.186.1",
+ },
+ "devDependencies": {
+ "@happy-dom/global-registrator": "^21.0.4",
+ "@types/bun": "^1.4.4",
+ "@types/three": "^0.186.0",
+ "typescript": "^7.0.2",
+ },
+ },
+ },
+ "packages": {
+ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
+
+ "@fontsource/cormorant-garamond": ["@fontsource/cormorant-garamond@5.3.0", "", {}, "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ=="],
+
+ "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
+
+ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^21.0.4" } }, "sha512-Bbr0hW9hhDhBf3Jp0EyDlkCbFgbr0z2jd3BMKYOYW+fOkPM4Nx195GMXyCtRCGEgoNn6i09FabXrSkNFm2ONzg=="],
+
+ "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
+
+ "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
+
+ "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
+
+ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
+
+ "@types/three": ["@types/three@0.186.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ=="],
+
+ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
+
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
+
+ "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
+
+ "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
+
+ "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
+
+ "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
+
+ "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
+
+ "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
+
+ "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
+
+ "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
+
+ "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
+
+ "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
+
+ "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
+
+ "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
+
+ "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
+
+ "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
+
+ "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
+
+ "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
+
+ "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
+
+ "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
+
+ "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
+
+ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
+
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
+ "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
+
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
+
+ "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="],
+
+ "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
+
+ "three": ["three@0.186.1", "", {}, "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="],
+
+ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
+
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
+
+ "bun-types/undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c65dd7d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "epic-the-musical-timeline",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "bun --hot src/server.ts",
+ "start": "cd dist && bun server.js",
+ "build": "bun build src/server.ts --target=bun --production --outdir dist",
+ "test": "bun test",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@fontsource/cormorant-garamond": "^5.3.0",
+ "@fontsource/inter": "^5.3.0",
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "@happy-dom/global-registrator": "^21.0.4",
+ "@types/bun": "^1.4.4",
+ "@types/three": "^0.186.0",
+ "typescript": "^7.0.2"
+ }
+}
diff --git a/src/data/query.ts b/src/data/query.ts
new file mode 100644
index 0000000..8398f84
--- /dev/null
+++ b/src/data/query.ts
@@ -0,0 +1,21 @@
+import { motifs, sagas, songs } from "./sample";
+import type { Motif, Saga, Song } from "./types";
+
+export function songsWithMotif(motifId: string): Song[] {
+ return songs.filter((s) => s.parts.some((p) => p.motifIds.includes(motifId)));
+}
+
+export function motifsInSong(song: Song): Motif[] {
+ const ids = [...new Set(song.parts.flatMap((p) => p.motifIds))];
+ return ids.flatMap((id) => motifs.find((m) => m.id === id) ?? []);
+}
+
+export function gapYears(a: Song, b: Song): number {
+ return b.storyTime.yearsAfterTroy - a.storyTime.yearsAfterTroy;
+}
+
+export function sagaOf(song: Song): Saga {
+ const saga = sagas.find((s) => s.id === song.sagaId);
+ if (!saga) throw new Error(`Unknown saga ${song.sagaId}`);
+ return saga;
+}
diff --git a/src/data/sample.ts b/src/data/sample.ts
new file mode 100644
index 0000000..d64bcf7
--- /dev/null
+++ b/src/data/sample.ts
@@ -0,0 +1,139 @@
+import type { Motif, Saga, Song } from "./types";
+
+// MOCKUP DATA: lyrics are placeholders, never real lyrics. Anything unverified is marked TODO(verify).
+
+const BUTLER = "https://www.gutenberg.org/ebooks/1727"; // Samuel Butler translation, public domain
+const yt = (q: string) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`;
+
+export const sagas: Saga[] = [
+ { id: "troy", name: "The Troy Saga", order: 1 },
+ { id: "cyclops", name: "The Cyclops Saga", order: 2 },
+ { id: "ocean", name: "The Ocean Saga", order: 3 }, // TODO(verify) saga name and order
+];
+
+export const motifs: Motif[] = [
+ { id: "warrior", name: "Warrior of the Mind", kind: "motif", color: "#d9a441", description: "Odysseus's cunning, strategy-first fighting theme." }, // TODO(verify)
+ { id: "open-arms", name: "Open Arms", kind: "motif", color: "#e8836b", description: "Welcoming, hospitable melody tied to home and guests." }, // TODO(verify)
+ { id: "just-a-man", name: "Just a Man", kind: "motif", color: "#7aa6c9", description: "A humble, human refrain about being only mortal." }, // TODO(verify)
+ { id: "remember", name: "Remember Them", kind: "motif", color: "#8fbf8a", description: "A memorial refrain for the fallen crew." }, // TODO(verify)
+ { id: "odysseus", name: "Odysseus", kind: "character", color: "#f2e3b3", description: "Theme of the king of Ithaca." },
+ { id: "athena", name: "Athena", kind: "character", color: "#b69bd6", description: "Theme of the goddess of wisdom." },
+ { id: "polyphemus", name: "Polyphemus", kind: "character", color: "#c0504d", description: "Theme of the Cyclops." },
+];
+
+const lyric = (who: string, n: number) => [
+ `[placeholder lyric — ${who}, verse ${n}, line 1]`,
+ `[placeholder lyric — ${who}, verse ${n}, line 2]`,
+];
+
+export const songs: Song[] = [
+ {
+ id: "the-horse-and-the-infant", title: "The Horse and the Infant", sagaId: "troy", track: 1,
+ storyTime: { yearsAfterTroy: 0, label: "The fall of Troy" }, // TODO(verify)
+ odyssey: { book: 8, lines: "492-520", url: BUTLER }, // TODO(verify) Demodocus sings of the wooden horse
+ parts: [
+ { name: "Verse 1", singers: ["Odysseus"], lines: lyric("Odysseus", 1), motifIds: ["odysseus", "warrior"] }, // TODO(verify) singers
+ { name: "Verse 2", singers: ["Athena", "Odysseus"], lines: lyric("Athena", 2), motifIds: ["athena", "warrior"], startSec: 45 }, // TODO(verify)
+ ],
+ facts: ["Opens the story at the end of the Trojan War."], // TODO(verify)
+ videos: [{ title: "The Horse and the Infant — search", url: yt("EPIC The Musical The Horse and the Infant") }],
+ },
+ {
+ id: "just-a-man", title: "Just a Man", sagaId: "troy", track: 2,
+ storyTime: { yearsAfterTroy: 0, label: "Aboard the ships, just after Troy" }, // TODO(verify)
+ odyssey: null, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Odysseus"], lines: lyric("Odysseus", 1), motifIds: ["odysseus", "just-a-man"] }, // TODO(verify)
+ { name: "Chorus", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["just-a-man"], startSec: 60 },
+ ],
+ facts: ["Shows Odysseus as a husband and father before the voyage."], // TODO(verify)
+ videos: [{ title: "Just a Man — search", url: yt("EPIC The Musical Just a Man") }],
+ },
+ {
+ id: "full-speed-ahead", title: "Full Speed Ahead", sagaId: "troy", track: 3,
+ storyTime: { yearsAfterTroy: 0, label: "Sailing from Troy" }, // TODO(verify)
+ odyssey: { book: 9, lines: "39-61", url: BUTLER }, // TODO(verify) raid on the Cicones
+ parts: [
+ { name: "Verse 1", singers: ["Eurylochus", "Odysseus"], lines: lyric("crew", 1), motifIds: ["odysseus"] }, // TODO(verify)
+ { name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["warrior"] },
+ ],
+ facts: ["The crew's high spirits are about to run into trouble."], // TODO(verify)
+ videos: [{ title: "Full Speed Ahead — search", url: yt("EPIC The Musical Full Speed Ahead") }],
+ },
+ {
+ id: "open-arms", title: "Open Arms", sagaId: "troy", track: 4,
+ storyTime: { yearsAfterTroy: 0, label: "Shortly after leaving Troy" }, // TODO(verify)
+ odyssey: null, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Odysseus"], lines: lyric("Odysseus", 1), motifIds: ["open-arms"] }, // TODO(verify)
+ { name: "Verse 2", singers: ["Odysseus", "Crew"], lines: lyric("crew", 2), motifIds: ["open-arms", "odysseus"] },
+ ],
+ facts: ["Introduces a welcoming melody that returns later."], // TODO(verify)
+ videos: [{ title: "Open Arms — search", url: yt("EPIC The Musical Open Arms") }],
+ },
+ {
+ id: "warrior-of-the-mind", title: "Warrior of the Mind", sagaId: "troy", track: 5,
+ storyTime: { yearsAfterTroy: 1, label: "About a year after Troy" }, // TODO(verify)
+ odyssey: { book: 13, lines: "287-310", url: BUTLER }, // TODO(verify) Athena and Odysseus
+ parts: [
+ { name: "Verse 1", singers: ["Athena"], lines: lyric("Athena", 1), motifIds: ["athena", "warrior"] }, // TODO(verify)
+ { name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["odysseus", "warrior"], startSec: 70 },
+ ],
+ facts: ["Athena teaches Odysseus to fight with wit."], // TODO(verify)
+ videos: [{ title: "Warrior of the Mind — search", url: yt("EPIC The Musical Warrior of the Mind") }],
+ },
+ {
+ id: "polyphemus", title: "Polyphemus", sagaId: "cyclops", track: 6,
+ storyTime: { yearsAfterTroy: 1, label: "The island of the Cyclopes" }, // TODO(verify)
+ odyssey: { book: 9, lines: "105-460", url: BUTLER }, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Polyphemus"], lines: lyric("Polyphemus", 1), motifIds: ["polyphemus"] },
+ { name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["odysseus", "warrior"], startSec: 50 },
+ ],
+ facts: ["Odysseus meets Polyphemus in his cave."], // TODO(verify)
+ videos: [{ title: "Polyphemus — search", url: yt("EPIC The Musical Polyphemus") }],
+ },
+ {
+ id: "survive", title: "Survive", sagaId: "cyclops", track: 7,
+ storyTime: { yearsAfterTroy: 1, label: "Inside the cave" }, // TODO(verify)
+ odyssey: { book: 9, lines: "287-412", url: BUTLER }, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Polyphemus", "Crew"], lines: lyric("Polyphemus", 1), motifIds: ["polyphemus"] }, // TODO(verify)
+ { name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["odysseus", "just-a-man"] },
+ ],
+ facts: ["The crew is trapped and Odysseus must choose a plan."], // TODO(verify)
+ videos: [{ title: "Survive — search", url: yt("EPIC The Musical Survive") }],
+ },
+ {
+ id: "remember-them", title: "Remember Them", sagaId: "cyclops", track: 8,
+ storyTime: { yearsAfterTroy: 1, label: "Escaping the cave" }, // TODO(verify)
+ odyssey: { book: 9, lines: "431-460", url: BUTLER }, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Telemachus"], lines: lyric("Telemachus", 1), motifIds: ["remember"] }, // TODO(verify)
+ { name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["remember", "open-arms"], startSec: 40 },
+ ],
+ facts: ["A memorial theme introduced here returns later."], // TODO(verify)
+ videos: [{ title: "Remember Them — search", url: yt("EPIC The Musical Remember Them") }],
+ },
+ {
+ id: "my-goodbye", title: "My Goodbye", sagaId: "cyclops", track: 9,
+ storyTime: { yearsAfterTroy: 1, label: "Leaving the island" }, // TODO(verify)
+ odyssey: { book: 9, lines: "473-542", url: BUTLER }, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Odysseus", "Polyphemus"], lines: lyric("Odysseus", 1), motifIds: ["polyphemus", "odysseus"] }, // TODO(verify)
+ ],
+ facts: ["The Cyclops saga ends with Odysseus revealing his name."], // TODO(verify)
+ videos: [{ title: "My Goodbye — search", url: yt("EPIC The Musical My Goodbye") }],
+ },
+ {
+ id: "storm", title: "Storm", sagaId: "ocean", track: 10, // TODO(verify) title, track and saga
+ storyTime: { yearsAfterTroy: 3, label: "Years later, at sea" }, // TODO(verify)
+ odyssey: { book: 10, lines: "1-76", url: BUTLER }, // TODO(verify)
+ parts: [
+ { name: "Verse 1", singers: ["Poseidon"], lines: lyric("Poseidon", 1), motifIds: ["polyphemus"] }, // TODO(verify) callback
+ { name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["remember", "warrior"], startSec: 30 }, // TODO(verify) callback
+ ],
+ facts: ["Callback song: motifs from the Troy and Cyclops sagas return."], // TODO(verify)
+ videos: [{ title: "Storm — search", url: yt("EPIC The Musical Storm") }],
+ },
+];
diff --git a/src/data/types.ts b/src/data/types.ts
new file mode 100644
index 0000000..b0db1e9
--- /dev/null
+++ b/src/data/types.ts
@@ -0,0 +1,12 @@
+export type MotifKind = "motif" | "character";
+export interface Motif { id: string; name: string; kind: MotifKind; color: string; description: string }
+export interface Saga { id: string; name: string; order: number }
+export interface OdysseyRef { book: number; lines: string; url: string }
+export interface StoryTime { yearsAfterTroy: number; label: string }
+export interface Part { name: string; singers: string[]; lines: string[]; motifIds: string[]; startSec?: number }
+export interface Video { title: string; url: string }
+export interface Song {
+ id: string; title: string; sagaId: string; track: number;
+ storyTime: StoryTime; odyssey: OdysseyRef | null; // null = no Homer counterpart
+ parts: Part[]; facts: string[]; videos: Video[]; audio?: string;
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..fa079aa
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,11 @@
+:root { color-scheme: dark; }
+body { margin: 0; font-family: system-ui, sans-serif; background: #12100e; color: #eee6d8; }
+main { max-width: 64rem; margin: 0 auto; padding: 3rem 1.5rem 1rem; }
+h1 { margin: 0 0 0.5rem; }
+.lede { opacity: 0.8; }
+.cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); gap: 1rem; }
+.card a { display: block; height: 100%; box-sizing: border-box; padding: 1rem; border: 1px solid #4a4238; border-radius: 8px; color: inherit; text-decoration: none; }
+.card a:hover { border-color: #c9a45c; }
+.card__num { opacity: 0.6; font-variant-numeric: tabular-nums; }
+.card h2 { margin: 0.25rem 0 0.5rem; }
+.card__try { font-size: 0.9rem; opacity: 0.75; }
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..71fbee9
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ EPIC: The Musical Timeline — design mockups
+
+
+
+
+
+ EPIC: The Musical Timeline
+ Seven design directions for an interactive timeline. Pick one, or mix several.
+
+ - 01
Editorial scroll
A clean horizontal line in a parchment-and-serif editorial style, with motifs as thin coloured threads underneath.
Try this: Pan sideways, notice gap length tracking in-story time, and open a song.
+ - 02
Vertical story
The voyage scrolls downward; songs reveal one at a time beside a sticky detail panel.
Try this: Scroll and watch the sticky detail panel update.
+ - 03
Transit map
Each motif is a coloured metro line and each song a station; shared stations are interchanges.
Try this: Follow a line between stations, find an interchange, and check the legend.
+ - 04
Greek frieze
The timeline as an unrolled black-figure vase frieze, panned sideways.
Try this: Pan the frieze and open a song panel.
+ - 05
Score / waveform
The line is a music staff or waveform; song parts are segments and motifs are recurring marks.
Try this: Look at song parts as segments and follow motif marks as they recur.
+ - 06
Constellation (3D)
Songs are stars along a path through space, joined by motif constellation lines (three.js).
Try this: Fly the camera through, hover a star, and open a song.
+ - 07
Sea voyage (3D)
A ship crosses a 3D sea past saga islands while the camera follows the route (three.js).
Try this: Watch the ship sail the route, spot the saga islands, and open a song.
+
+
+
+
+
+
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..855cd03
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,4 @@
+import { mountCredits } from "./shared/credits";
+
+// Cards are static in index.html (kept in sync with src/mockups/registry.ts by tests/server.test.ts).
+mountCredits();
diff --git a/src/mockups/01-editorial/index.html b/src/mockups/01-editorial/index.html
new file mode 100644
index 0000000..6667683
--- /dev/null
+++ b/src/mockups/01-editorial/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Editorial scroll — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/01-editorial/main.ts b/src/mockups/01-editorial/main.ts
new file mode 100644
index 0000000..bd5b800
--- /dev/null
+++ b/src/mockups/01-editorial/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Editorial scroll
stub
`;
diff --git a/src/mockups/01-editorial/style.css b/src/mockups/01-editorial/style.css
new file mode 100644
index 0000000..e76b129
--- /dev/null
+++ b/src/mockups/01-editorial/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "01 · "; }
diff --git a/src/mockups/02-vertical-story/index.html b/src/mockups/02-vertical-story/index.html
new file mode 100644
index 0000000..ff226a2
--- /dev/null
+++ b/src/mockups/02-vertical-story/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Vertical story — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/02-vertical-story/main.ts b/src/mockups/02-vertical-story/main.ts
new file mode 100644
index 0000000..e4cecdc
--- /dev/null
+++ b/src/mockups/02-vertical-story/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Vertical story
stub
`;
diff --git a/src/mockups/02-vertical-story/style.css b/src/mockups/02-vertical-story/style.css
new file mode 100644
index 0000000..91bc818
--- /dev/null
+++ b/src/mockups/02-vertical-story/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "02 · "; }
diff --git a/src/mockups/03-transit-map/index.html b/src/mockups/03-transit-map/index.html
new file mode 100644
index 0000000..b5e3762
--- /dev/null
+++ b/src/mockups/03-transit-map/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Transit map — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/03-transit-map/main.ts b/src/mockups/03-transit-map/main.ts
new file mode 100644
index 0000000..cbd9e18
--- /dev/null
+++ b/src/mockups/03-transit-map/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Transit map
stub
`;
diff --git a/src/mockups/03-transit-map/style.css b/src/mockups/03-transit-map/style.css
new file mode 100644
index 0000000..5c9057d
--- /dev/null
+++ b/src/mockups/03-transit-map/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "03 · "; }
diff --git a/src/mockups/04-greek-frieze/index.html b/src/mockups/04-greek-frieze/index.html
new file mode 100644
index 0000000..f06824e
--- /dev/null
+++ b/src/mockups/04-greek-frieze/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Greek frieze — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/04-greek-frieze/main.ts b/src/mockups/04-greek-frieze/main.ts
new file mode 100644
index 0000000..94abd09
--- /dev/null
+++ b/src/mockups/04-greek-frieze/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Greek frieze
stub
`;
diff --git a/src/mockups/04-greek-frieze/style.css b/src/mockups/04-greek-frieze/style.css
new file mode 100644
index 0000000..d5949f1
--- /dev/null
+++ b/src/mockups/04-greek-frieze/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "04 · "; }
diff --git a/src/mockups/05-score/index.html b/src/mockups/05-score/index.html
new file mode 100644
index 0000000..816ac53
--- /dev/null
+++ b/src/mockups/05-score/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Score / waveform — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/05-score/main.ts b/src/mockups/05-score/main.ts
new file mode 100644
index 0000000..8c3a351
--- /dev/null
+++ b/src/mockups/05-score/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Score / waveform
stub
`;
diff --git a/src/mockups/05-score/style.css b/src/mockups/05-score/style.css
new file mode 100644
index 0000000..b3817b7
--- /dev/null
+++ b/src/mockups/05-score/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "05 · "; }
diff --git a/src/mockups/06-constellation/index.html b/src/mockups/06-constellation/index.html
new file mode 100644
index 0000000..dd7638d
--- /dev/null
+++ b/src/mockups/06-constellation/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Constellation (3D) — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/06-constellation/main.ts b/src/mockups/06-constellation/main.ts
new file mode 100644
index 0000000..856977a
--- /dev/null
+++ b/src/mockups/06-constellation/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Constellation (3D)
stub
`;
diff --git a/src/mockups/06-constellation/style.css b/src/mockups/06-constellation/style.css
new file mode 100644
index 0000000..ef862b4
--- /dev/null
+++ b/src/mockups/06-constellation/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "06 · "; }
diff --git a/src/mockups/07-sea-voyage/index.html b/src/mockups/07-sea-voyage/index.html
new file mode 100644
index 0000000..b7a3229
--- /dev/null
+++ b/src/mockups/07-sea-voyage/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sea voyage (3D) — EPIC Timeline mockup
+
+
+
+
+
+
+
+
+
diff --git a/src/mockups/07-sea-voyage/main.ts b/src/mockups/07-sea-voyage/main.ts
new file mode 100644
index 0000000..c737fea
--- /dev/null
+++ b/src/mockups/07-sea-voyage/main.ts
@@ -0,0 +1,5 @@
+import { mountCredits } from "../../shared/credits";
+
+mountCredits();
+const app = document.getElementById("app")!;
+app.innerHTML = `Sea voyage (3D)
stub
`;
diff --git a/src/mockups/07-sea-voyage/style.css b/src/mockups/07-sea-voyage/style.css
new file mode 100644
index 0000000..1946617
--- /dev/null
+++ b/src/mockups/07-sea-voyage/style.css
@@ -0,0 +1,2 @@
+main { padding: 2rem; font-family: system-ui, sans-serif; }
+h1::before { content: "07 · "; }
diff --git a/src/mockups/registry.ts b/src/mockups/registry.ts
new file mode 100644
index 0000000..a07af4f
--- /dev/null
+++ b/src/mockups/registry.ts
@@ -0,0 +1,25 @@
+export interface MockupInfo { slug: string; number: number; name: string; pitch: string; tryThis: string }
+
+export const mockups: MockupInfo[] = [
+ { slug: "01-editorial", number: 1, name: "Editorial scroll",
+ pitch: "A clean horizontal line in a parchment-and-serif editorial style, with motifs as thin coloured threads underneath.",
+ tryThis: "Pan sideways, notice gap length tracking in-story time, and open a song." },
+ { slug: "02-vertical-story", number: 2, name: "Vertical story",
+ pitch: "The voyage scrolls downward; songs reveal one at a time beside a sticky detail panel.",
+ tryThis: "Scroll and watch the sticky detail panel update." },
+ { slug: "03-transit-map", number: 3, name: "Transit map",
+ pitch: "Each motif is a coloured metro line and each song a station; shared stations are interchanges.",
+ tryThis: "Follow a line between stations, find an interchange, and check the legend." },
+ { slug: "04-greek-frieze", number: 4, name: "Greek frieze",
+ pitch: "The timeline as an unrolled black-figure vase frieze, panned sideways.",
+ tryThis: "Pan the frieze and open a song panel." },
+ { slug: "05-score", number: 5, name: "Score / waveform",
+ pitch: "The line is a music staff or waveform; song parts are segments and motifs are recurring marks.",
+ tryThis: "Look at song parts as segments and follow motif marks as they recur." },
+ { slug: "06-constellation", number: 6, name: "Constellation (3D)",
+ pitch: "Songs are stars along a path through space, joined by motif constellation lines (three.js).",
+ tryThis: "Fly the camera through, hover a star, and open a song." },
+ { slug: "07-sea-voyage", number: 7, name: "Sea voyage (3D)",
+ pitch: "A ship crosses a 3D sea past saga islands while the camera follows the route (three.js).",
+ tryThis: "Watch the ship sail the route, spot the saga islands, and open a song." },
+];
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..a886dec
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,41 @@
+import type { Server } from "bun";
+import home from "./index.html";
+import m0 from "./mockups/01-editorial/index.html";
+import m1 from "./mockups/02-vertical-story/index.html";
+import m2 from "./mockups/03-transit-map/index.html";
+import m3 from "./mockups/04-greek-frieze/index.html";
+import m4 from "./mockups/05-score/index.html";
+import m5 from "./mockups/06-constellation/index.html";
+import m6 from "./mockups/07-sea-voyage/index.html";
+
+export function startServer(port: number): Server {
+ return Bun.serve({
+ port,
+ routes: {
+ "/": home,
+ "/mockups/01-editorial/": m0,
+ "/mockups/01-editorial": Response.redirect("/mockups/01-editorial/", 302),
+ "/mockups/02-vertical-story/": m1,
+ "/mockups/02-vertical-story": Response.redirect("/mockups/02-vertical-story/", 302),
+ "/mockups/03-transit-map/": m2,
+ "/mockups/03-transit-map": Response.redirect("/mockups/03-transit-map/", 302),
+ "/mockups/04-greek-frieze/": m3,
+ "/mockups/04-greek-frieze": Response.redirect("/mockups/04-greek-frieze/", 302),
+ "/mockups/05-score/": m4,
+ "/mockups/05-score": Response.redirect("/mockups/05-score/", 302),
+ "/mockups/06-constellation/": m5,
+ "/mockups/06-constellation": Response.redirect("/mockups/06-constellation/", 302),
+ "/mockups/07-sea-voyage/": m6,
+ "/mockups/07-sea-voyage": Response.redirect("/mockups/07-sea-voyage/", 302),
+ },
+ development: process.env.NODE_ENV !== "production",
+ fetch() {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+}
+
+if (import.meta.main) {
+ const server = startServer(Number(process.env.PORT ?? 3000));
+ console.log(`Listening on ${server.url}`);
+}
diff --git a/src/shared/audio-slot.ts b/src/shared/audio-slot.ts
new file mode 100644
index 0000000..30273dc
--- /dev/null
+++ b/src/shared/audio-slot.ts
@@ -0,0 +1,29 @@
+import type { Song } from "../data/types";
+
+export function createAudioSlot(song: Song): HTMLElement {
+ const root = document.createElement("div");
+ root.className = "audio-slot";
+ if (!song.audio) {
+ root.classList.add("audio-slot--empty");
+ root.textContent = "No audio file for this song yet.";
+ return root;
+ }
+ const audio = document.createElement("audio");
+ audio.controls = true;
+ audio.src = song.audio;
+ root.append(audio);
+ for (const part of song.parts) {
+ if (part.startSec === undefined) continue;
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "audio-slot__seek";
+ btn.dataset.start = String(part.startSec);
+ btn.textContent = part.name;
+ btn.addEventListener("click", () => {
+ audio.currentTime = part.startSec!;
+ void audio.play?.();
+ });
+ root.append(btn);
+ }
+ return root;
+}
diff --git a/src/shared/credits.css b/src/shared/credits.css
new file mode 100644
index 0000000..1d03470
--- /dev/null
+++ b/src/shared/credits.css
@@ -0,0 +1,9 @@
+.fan-credits { padding: 1rem 1.5rem; font-size: 0.8rem; opacity: 0.75; text-align: center; }
+.fan-credits p { margin: 0; }
+.audio-slot { padding: 0.75rem; border: 1px solid currentColor; border-radius: 6px; }
+.audio-slot--empty { opacity: 0.6; border-style: dashed; }
+.audio-slot audio { width: 100%; }
+.audio-slot__seek { margin: 0.25rem 0.25rem 0 0; }
+.video-cards { display: flex; flex-wrap: wrap; gap: 0.5rem; }
+.video-card { display: block; padding: 0.5rem 0.75rem; border: 1px solid currentColor; border-radius: 6px; color: inherit; text-decoration: none; }
+.video-card:hover { opacity: 0.8; }
diff --git a/src/shared/credits.ts b/src/shared/credits.ts
new file mode 100644
index 0000000..d7d7304
--- /dev/null
+++ b/src/shared/credits.ts
@@ -0,0 +1,13 @@
+export const FAN_NOTICE =
+ "This is an unofficial fan project, not affiliated with or endorsed by the creator of EPIC: The Musical. " +
+ "With thanks to Jorge Rivera-Herrans, creator of EPIC.";
+
+export function mountCredits(target?: Element): HTMLElement {
+ const el = (target ?? document.querySelector("[data-fan-credits]") ?? document.body.appendChild(document.createElement("footer"))) as HTMLElement;
+ el.setAttribute("data-fan-credits", "");
+ el.classList.add("fan-credits");
+ const p = document.createElement("p");
+ p.textContent = FAN_NOTICE;
+ el.replaceChildren(p);
+ return el;
+}
diff --git a/src/shared/video-cards.ts b/src/shared/video-cards.ts
new file mode 100644
index 0000000..a5bc5eb
--- /dev/null
+++ b/src/shared/video-cards.ts
@@ -0,0 +1,16 @@
+import type { Song } from "../data/types";
+
+export function createVideoCards(song: Song): HTMLElement {
+ const root = document.createElement("div");
+ root.className = "video-cards";
+ for (const v of song.videos) {
+ const a = document.createElement("a");
+ a.className = "video-card";
+ a.href = v.url;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ a.textContent = v.title;
+ root.append(a);
+ }
+ return root;
+}
diff --git a/tests/data.test.ts b/tests/data.test.ts
new file mode 100644
index 0000000..bdd2c6d
--- /dev/null
+++ b/tests/data.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from "bun:test";
+import { motifs, sagas, songs } from "../src/data/sample";
+import { gapYears, motifsInSong, sagaOf, songsWithMotif } from "../src/data/query";
+
+describe("sample data", () => {
+ test("every song references a known saga", () => {
+ const ids = new Set(sagas.map((s) => s.id));
+ for (const song of songs) expect(ids.has(song.sagaId)).toBe(true);
+ });
+ test("every part motif id exists", () => {
+ const ids = new Set(motifs.map((m) => m.id));
+ for (const song of songs) for (const part of song.parts) for (const id of part.motifIds) expect(ids.has(id)).toBe(true);
+ });
+ test("every motif appears in at least one song", () => {
+ for (const m of motifs) expect(songsWithMotif(m.id).length).toBeGreaterThanOrEqual(1);
+ });
+ test("saga coverage", () => {
+ expect(songs.filter((s) => s.sagaId === "troy")).toHaveLength(5);
+ expect(songs.filter((s) => s.sagaId === "cyclops")).toHaveLength(4);
+ expect(songs.filter((s) => s.sagaId !== "troy" && s.sagaId !== "cyclops").length).toBeGreaterThanOrEqual(1);
+ });
+ test("every song has parts, facts and videos", () => {
+ for (const s of songs) {
+ expect(s.parts.length).toBeGreaterThanOrEqual(1);
+ expect(s.facts.length).toBeGreaterThanOrEqual(1);
+ expect(s.videos.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+ test("songs are sorted by track and the first gap is non-negative", () => {
+ const tracks = songs.map((s) => s.track);
+ expect(tracks).toEqual([...tracks].sort((a, b) => a - b));
+ expect(gapYears(songs[0]!, songs[1]!)).toBeGreaterThanOrEqual(0);
+ });
+ test("ids are unique", () => {
+ for (const list of [songs, motifs, sagas]) expect(new Set(list.map((x) => x.id)).size).toBe(list.length);
+ });
+ test("motif mix and distinct colours", () => {
+ expect(motifs.filter((m) => m.kind === "motif").length).toBeGreaterThanOrEqual(4);
+ expect(motifs.filter((m) => m.kind === "character").length).toBeGreaterThanOrEqual(3);
+ expect(new Set(motifs.map((m) => m.color)).size).toBe(motifs.length);
+ });
+ test("a later song reuses an earlier motif", () => {
+ const later = songs.filter((s) => s.sagaId !== "troy" && s.sagaId !== "cyclops");
+ const earlier = new Set(songs.filter((s) => s.sagaId === "troy" || s.sagaId === "cyclops").flatMap((s) => motifsInSong(s).map((m) => m.id)));
+ expect(later.some((s) => motifsInSong(s).some((m) => earlier.has(m.id)))).toBe(true);
+ });
+ test("motifsInSong is unique and in first-appearance order; sagaOf resolves", () => {
+ const s = songs[0]!;
+ const ids = motifsInSong(s).map((m) => m.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ expect(ids).toEqual([...new Set(s.parts.flatMap((p) => p.motifIds))]);
+ expect(sagaOf(s).id).toBe(s.sagaId);
+ });
+});
diff --git a/tests/server.test.ts b/tests/server.test.ts
new file mode 100644
index 0000000..7e55518
--- /dev/null
+++ b/tests/server.test.ts
@@ -0,0 +1,31 @@
+import { afterAll, beforeAll, expect, test } from "bun:test";
+import type { Server } from "bun";
+import { startServer } from "../src/server";
+import { mockups } from "../src/mockups/registry";
+
+let server: Server;
+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("index links every mockup", async () => {
+ const res = await fetch(base + "/");
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ for (const m of mockups) expect(html).toContain(`/mockups/${m.slug}/`);
+});
+
+for (const m of mockups) {
+ test(`mockup ${m.slug} serves with credits hook`, async () => {
+ const res = await fetch(`${base}/mockups/${m.slug}/`);
+ expect(res.status).toBe(200);
+ expect(await res.text()).toContain("data-fan-credits");
+ });
+ test(`mockup ${m.slug} without slash is ok or redirects`, async () => {
+ const res = await fetch(`${base}/mockups/${m.slug}`, { redirect: "manual" });
+ if (res.status >= 300 && res.status < 400) expect(res.headers.get("location")).toContain(`/mockups/${m.slug}/`);
+ else expect(res.status).toBe(200);
+ });
+}
diff --git a/tests/shared.test.ts b/tests/shared.test.ts
new file mode 100644
index 0000000..4030602
--- /dev/null
+++ b/tests/shared.test.ts
@@ -0,0 +1,64 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import type { Song } from "../src/data/types";
+
+beforeAll(() => GlobalRegistrator.register());
+afterAll(() => GlobalRegistrator.unregister());
+
+const base: Song = {
+ id: "x", title: "X", sagaId: "troy", track: 1,
+ storyTime: { yearsAfterTroy: 0, label: "t" }, odyssey: null,
+ parts: [{ name: "Verse 1", singers: ["A"], lines: ["l"], motifIds: [] }],
+ facts: ["f"], videos: [{ title: "v1", url: "https://example.com/1" }, { title: "v2", url: "https://example.com/2" }],
+};
+
+describe("credits", () => {
+ test("FAN_NOTICE has required phrases", async () => {
+ const { FAN_NOTICE } = await import("../src/shared/credits");
+ for (const p of ["unofficial", "not affiliated", "Jorge Rivera-Herrans"]) expect(FAN_NOTICE).toContain(p);
+ });
+ test("mountCredits puts the notice in document.body", async () => {
+ const { FAN_NOTICE, mountCredits } = await import("../src/shared/credits");
+ mountCredits();
+ expect(document.body.textContent).toContain(FAN_NOTICE);
+ });
+ test("mountCredits fills a [data-fan-credits] target", async () => {
+ const { FAN_NOTICE, mountCredits } = await import("../src/shared/credits");
+ document.body.innerHTML = '';
+ const el = mountCredits();
+ expect(el.hasAttribute("data-fan-credits")).toBe(true);
+ expect(document.querySelectorAll("[data-fan-credits]")).toHaveLength(1);
+ expect(el.textContent).toContain(FAN_NOTICE);
+ });
+});
+
+describe("createAudioSlot", () => {
+ test("createAudioSlot empty state", async () => {
+ const { createAudioSlot } = await import("../src/shared/audio-slot");
+ const el = createAudioSlot(base);
+ expect(el.classList.contains("audio-slot")).toBe(true);
+ expect(el.classList.contains("audio-slot--empty")).toBe(true);
+ expect(el.querySelector("audio")).toBeNull();
+ });
+ test("with audio and startSec renders player and seek button", async () => {
+ const { createAudioSlot } = await import("../src/shared/audio-slot");
+ const song: Song = { ...base, audio: "/a.mp3", parts: [{ ...base.parts[0]!, startSec: 30 }] };
+ const el = createAudioSlot(song);
+ expect(el.classList.contains("audio-slot--empty")).toBe(false);
+ expect(el.querySelectorAll("audio[src]")).toHaveLength(1);
+ expect(el.querySelectorAll('button[data-start="30"]')).toHaveLength(1);
+ });
+});
+
+describe("createVideoCards", () => {
+ test("one safe link per video", async () => {
+ const { createVideoCards } = await import("../src/shared/video-cards");
+ const el = createVideoCards(base);
+ const links = el.querySelectorAll("a.video-card");
+ expect(links).toHaveLength(2);
+ for (const a of links) {
+ expect(a.getAttribute("rel")).toContain("noopener");
+ expect(a.getAttribute("target")).toBe("_blank");
+ }
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..402a1b7
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": false,
+ "types": ["bun"],
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ }
+}