feat: bun app foundation, sample data, shared credits/audio, mockup stubs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-23 18:19:36 +02:00
co-authored by Claude Opus 5.5
parent d52ac63afe
commit 10ce858b31
42 changed files with 839 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
.superpowers
.worktrees
screenshots
.env*
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env*
CLAUDE.local.md
.worktrees/
screenshots/
+14
View File
@@ -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"]
+109
View File
@@ -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/[email protected]", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@fontsource/cormorant-garamond": ["@fontsource/[email protected]", "", {}, "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ=="],
"@fontsource/inter": ["@fontsource/[email protected]", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
"@happy-dom/global-registrator": ["@happy-dom/[email protected]", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^21.0.4" } }, "sha512-Bbr0hW9hhDhBf3Jp0EyDlkCbFgbr0z2jd3BMKYOYW+fOkPM4Nx195GMXyCtRCGEgoNn6i09FabXrSkNFm2ONzg=="],
"@tweenjs/tween.js": ["@tweenjs/[email protected]", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/[email protected]", "", { "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/[email protected]", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@types/whatwg-mimetype": ["@types/[email protected]", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"happy-dom": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"three": ["[email protected]", "", {}, "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="],
"typescript": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"whatwg-mimetype": ["[email protected]", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"ws": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
}
}
+23
View File
@@ -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"
}
}
+21
View File
@@ -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;
}
+139
View File
@@ -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") }],
},
];
+12
View File
@@ -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;
}
+11
View File
@@ -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; }
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EPIC: The Musical Timeline — design mockups</title>
<link rel="stylesheet" href="./index.css" />
<link rel="stylesheet" href="./shared/credits.css" />
</head>
<body>
<main>
<h1>EPIC: The Musical Timeline</h1>
<p class="lede">Seven design directions for an interactive timeline. Pick one, or mix several.</p>
<ul class="cards">
<li class="card"><a href="/mockups/01-editorial/"><span class="card__num">01</span><h2>Editorial scroll</h2><p>A clean horizontal line in a parchment-and-serif editorial style, with motifs as thin coloured threads underneath.</p><p class="card__try">Try this: Pan sideways, notice gap length tracking in-story time, and open a song.</p></a></li>
<li class="card"><a href="/mockups/02-vertical-story/"><span class="card__num">02</span><h2>Vertical story</h2><p>The voyage scrolls downward; songs reveal one at a time beside a sticky detail panel.</p><p class="card__try">Try this: Scroll and watch the sticky detail panel update.</p></a></li>
<li class="card"><a href="/mockups/03-transit-map/"><span class="card__num">03</span><h2>Transit map</h2><p>Each motif is a coloured metro line and each song a station; shared stations are interchanges.</p><p class="card__try">Try this: Follow a line between stations, find an interchange, and check the legend.</p></a></li>
<li class="card"><a href="/mockups/04-greek-frieze/"><span class="card__num">04</span><h2>Greek frieze</h2><p>The timeline as an unrolled black-figure vase frieze, panned sideways.</p><p class="card__try">Try this: Pan the frieze and open a song panel.</p></a></li>
<li class="card"><a href="/mockups/05-score/"><span class="card__num">05</span><h2>Score / waveform</h2><p>The line is a music staff or waveform; song parts are segments and motifs are recurring marks.</p><p class="card__try">Try this: Look at song parts as segments and follow motif marks as they recur.</p></a></li>
<li class="card"><a href="/mockups/06-constellation/"><span class="card__num">06</span><h2>Constellation (3D)</h2><p>Songs are stars along a path through space, joined by motif constellation lines (three.js).</p><p class="card__try">Try this: Fly the camera through, hover a star, and open a song.</p></a></li>
<li class="card"><a href="/mockups/07-sea-voyage/"><span class="card__num">07</span><h2>Sea voyage (3D)</h2><p>A ship crosses a 3D sea past saga islands while the camera follows the route (three.js).</p><p class="card__try">Try this: Watch the ship sail the route, spot the saga islands, and open a song.</p></a></li>
</ul>
</main>
<footer data-fan-credits></footer>
<script type="module" src="./index.ts"></script>
</body>
</html>
+4
View File
@@ -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();
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Editorial scroll — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Editorial scroll</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "01 · "; }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vertical story — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Vertical story</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "02 · "; }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Transit map — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Transit map</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "03 · "; }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Greek frieze — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Greek frieze</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "04 · "; }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Score / waveform — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Score / waveform</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "05 · "; }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Constellation (3D) — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Constellation (3D)</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "06 · "; }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sea voyage (3D) — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { mountCredits } from "../../shared/credits";
mountCredits();
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Sea voyage (3D)</h1><p>stub</p>`;
+2
View File
@@ -0,0 +1,2 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "07 · "; }
+25
View File
@@ -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." },
];
+41
View File
@@ -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<undefined> {
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}`);
}
+29
View File
@@ -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;
}
+9
View File
@@ -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; }
+13
View File
@@ -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;
}
+16
View File
@@ -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;
}
+54
View File
@@ -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);
});
});
+31
View File
@@ -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<undefined>;
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);
});
}
+64
View File
@@ -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 = '<footer data-fan-credits></footer>';
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");
}
});
});
+30
View File
@@ -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
}
}