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.

+ +
+ + + + 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 + } +}