feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
225
frontend/package-lock.json
generated
225
frontend/package-lock.json
generated
@@ -8,8 +8,15 @@
|
||||
"name": "quantumancy-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/cinzel": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@types/three": "^0.185.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -290,7 +297,6 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -459,6 +465,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -850,6 +862,24 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/cinzel": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz",
|
||||
"integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1386,6 +1416,12 @@
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
@@ -1474,6 +1510,32 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1809,6 +1871,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@@ -2081,6 +2156,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
@@ -2240,6 +2321,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -2268,6 +2358,34 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -2431,6 +2549,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
@@ -2624,6 +2748,33 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -2642,6 +2793,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -2747,6 +2936,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -2798,6 +2993,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -2892,7 +3093,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -2933,6 +3134,15 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -3082,6 +3292,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/cinzel": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@types/three": "^0.185.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
|
||||
@@ -1,84 +1,134 @@
|
||||
/* Quantumancy global theme — gothic × hacker. Page-specific styles live in
|
||||
each page's own CSS file; this is only the shared foundation. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--qm-bg: #07070d;
|
||||
--qm-panel: rgba(16, 16, 26, 0.85);
|
||||
--qm-green: #7cffb2;
|
||||
--qm-violet: #b26bff;
|
||||
--qm-blood: #ff3b5c;
|
||||
--qm-text: #d6e4dc;
|
||||
--qm-dim: #8a8f98;
|
||||
--qm-border: rgba(124, 255, 178, 0.18);
|
||||
--qm-font-ui: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
--qm-font-display: 'Cinzel', Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--qm-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #0a0a0f;
|
||||
color: #e6e6f0;
|
||||
font-family: Georgia, serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--qm-text);
|
||||
font-family: var(--qm-font-ui);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(90vw, 420px);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.display {
|
||||
font-family: var(--qm-font-display);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.25rem;
|
||||
a {
|
||||
color: var(--qm-green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
opacity: 0.6;
|
||||
margin-bottom: 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
input {
|
||||
background: #16161f;
|
||||
border: 1px solid #333;
|
||||
color: inherit;
|
||||
padding: 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
a:hover {
|
||||
text-shadow: 0 0 8px rgba(124, 255, 178, 0.6);
|
||||
}
|
||||
|
||||
button {
|
||||
background: #2a1f3d;
|
||||
border: 1px solid #5a3f8f;
|
||||
color: inherit;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--qm-font-ui);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #3a2a55;
|
||||
input,
|
||||
textarea {
|
||||
font-family: var(--qm-font-ui);
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
::selection {
|
||||
background: rgba(178, 107, 255, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-toggle button.active {
|
||||
background: #5a3f8f;
|
||||
/* Thin phosphor scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0a12;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 255, 178, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.9rem;
|
||||
/* Global CRT glass: scanlines + vignette above every page, never clickable. */
|
||||
.crt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.crt-scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.14) 3px,
|
||||
rgba(0, 0, 0, 0.14) 4px
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.crt-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
transparent 55%,
|
||||
rgba(0, 0, 0, 0.55) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Shared focus glow */
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--qm-green);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 12px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,64 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import './i18n'
|
||||
import App from './App'
|
||||
import * as api from './api'
|
||||
|
||||
vi.mock('./api')
|
||||
|
||||
const statsBody = { entities: 7, sessions: 42, utterances: 133, anomalies: 1024 }
|
||||
const codexBody = { entities: [] }
|
||||
|
||||
function mockFetch() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
const body = url.includes('/api/stats') ? statsBody : codexBody
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the Quantumancy title', async () => {
|
||||
beforeEach(() => {
|
||||
mockFetch()
|
||||
vi.mocked(api.me).mockRejectedValue(new Error('not authenticated'))
|
||||
render(<App />)
|
||||
await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('logs in and shows the contact-established state', async () => {
|
||||
vi.mocked(api.me).mockRejectedValue(new Error('not authenticated'))
|
||||
vi.mocked(api.login).mockResolvedValue({ id: '1', username: 'medium1' })
|
||||
it('renders the landing page with the Quantumancy title', async () => {
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument())
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Username'), 'medium1')
|
||||
await userEvent.type(screen.getByLabelText('Password'), 'spookyspooky')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Enter' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Contact established as medium1.')).toBeInTheDocument(),
|
||||
expect(screen.getAllByText('QUANTUMANCY').length).toBeGreaterThan(0),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows live veil activity from /api/stats', async () => {
|
||||
render(<App />)
|
||||
// counters animate up from 0 over ~900ms — wait for the final values
|
||||
await waitFor(
|
||||
() => {
|
||||
const values = Array.from(document.querySelectorAll('.stat-value')).map(
|
||||
(el) => el.textContent,
|
||||
)
|
||||
expect(values).toContain('42')
|
||||
expect(values).toContain('7')
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('sends anonymous visitors to /enter from the séance CTA', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('QUANTUMANCY').length).toBeGreaterThan(0),
|
||||
)
|
||||
const cta = screen.getByRole('button', { name: /begin the séance/i })
|
||||
await userEvent.click(cta)
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/enter'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,95 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { login, logout, me, register, type User } from './api'
|
||||
// App shell: router + providers + the global CRT overlay (scanlines/vignette)
|
||||
// that sits above every page like old glass.
|
||||
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './state/auth'
|
||||
import { CodexEntityPage } from './pages/CodexEntityPage'
|
||||
import { CodexPage } from './pages/CodexPage'
|
||||
import { EnterPage } from './pages/EnterPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { SeancePage } from './pages/SeancePage'
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [checkingSession, setCheckingSession] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
me()
|
||||
.then(setUser)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setCheckingSession(false))
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
if (mode === 'register') {
|
||||
await register(username, password)
|
||||
}
|
||||
const loggedInUser = await login(username, password)
|
||||
setUser(loggedInUser)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<main className="app">
|
||||
<p>Listening for a signal...</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<h1>Quantumancy</h1>
|
||||
<p className="tagline">Something is listening on the other side.</p>
|
||||
|
||||
{user ? (
|
||||
<section>
|
||||
<p>Contact established as {user.username}.</p>
|
||||
<button onClick={handleLogout}>End session</button>
|
||||
</section>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === 'login' ? 'active' : ''}
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={mode === 'register' ? 'active' : ''}
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
First contact
|
||||
</button>
|
||||
</div>
|
||||
<label>
|
||||
Username
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit">{mode === 'login' ? 'Enter' : 'Begin'}</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<div className="crt-overlay" aria-hidden="true">
|
||||
<div className="crt-scanlines" />
|
||||
<div className="crt-vignette" />
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/enter" element={<EnterPage />} />
|
||||
<Route path="/seance" element={<SeancePage />} />
|
||||
<Route path="/codex" element={<CodexPage />} />
|
||||
<Route path="/codex/:id" element={<CodexEntityPage />} />
|
||||
<Route path="*" element={<LandingPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
79
frontend/src/components/EntityCard.tsx
Normal file
79
frontend/src/components/EntityCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SpiritEntity } from '../lib/types'
|
||||
import { GhostGlyph } from './GhostGlyph'
|
||||
|
||||
export type EntityCardProps = {
|
||||
entity: SpiritEntity | null
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
const RARITY_CLASS: Record<string, string> = {
|
||||
common: 'rarity-common',
|
||||
uncommon: 'rarity-uncommon',
|
||||
rare: 'rarity-rare',
|
||||
mythic: 'rarity-mythic',
|
||||
}
|
||||
|
||||
/** Occult dossier of the currently-summoned spirit, wax-seal vibe. */
|
||||
export function EntityCard({ entity, isNew }: EntityCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!entity) {
|
||||
return (
|
||||
<aside className="entity-card empty">
|
||||
<h3 className="ec-title">{t('seance.entity.title')}</h3>
|
||||
<div className="ec-none">
|
||||
<div className="ec-none-sigil">⟁</div>
|
||||
<p>{t('seance.entity.none')}</p>
|
||||
<p className="dim">{t('seance.entity.noneHint')}</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="entity-card">
|
||||
<div className="ec-wax" aria-hidden="true">
|
||||
{entity.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<h3 className="ec-title">{t('seance.entity.title')}</h3>
|
||||
<div className="ec-head">
|
||||
<GhostGlyph hue={entity.visual.hue} form={entity.visual.form} size={64} />
|
||||
<div>
|
||||
<div className="ec-name glitch-hover">{entity.name}</div>
|
||||
<div className="ec-epithet">{entity.epithet}</div>
|
||||
<span className={`rarity-badge ${RARITY_CLASS[entity.rarity] ?? ''}`}>
|
||||
{t(`seance.entity.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ec-contact">
|
||||
{isNew ? t('seance.entity.isNew') : t('seance.entity.known', { count: entity.contact_count })}
|
||||
</div>
|
||||
<div className="ec-section">
|
||||
<h4>{t('seance.entity.persona')}</h4>
|
||||
<p className="ec-persona">{entity.persona}</p>
|
||||
</div>
|
||||
{entity.quotes.length > 0 && (
|
||||
<div className="ec-section">
|
||||
<h4>{t('seance.entity.quotes')}</h4>
|
||||
<ul className="ec-quotes">
|
||||
{entity.quotes.slice(0, 4).map((q, i) => (
|
||||
<li key={i}>“{q}”</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="ec-section">
|
||||
<h4>{t('seance.entity.voice')}</h4>
|
||||
<div className="ec-voice">
|
||||
<span>id {entity.voice.voice_id}</span>
|
||||
<span>pitch {entity.voice.pitch.toFixed(2)}</span>
|
||||
<span>rate {entity.voice.rate.toFixed(2)}</span>
|
||||
<span>noise {entity.voice.noise.toFixed(2)}</span>
|
||||
<span>echo {entity.voice.echo.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/GhostGlyph.tsx
Normal file
63
frontend/src/components/GhostGlyph.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// Mini ghost glyph: an SVG spirit silhouette tinted by visual.hue,
|
||||
// with small per-form variations (fairy motes, banshee veil, shade dark).
|
||||
|
||||
import type { GhostForm } from '../lib/types'
|
||||
|
||||
export type GhostGlyphProps = {
|
||||
hue: number
|
||||
form: GhostForm
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GhostGlyph({ hue, form, size = 48, className }: GhostGlyphProps) {
|
||||
const body = `hsl(${hue} 75% ${form === 'shade' ? 22 : 62}%)`
|
||||
const glow = `hsl(${hue} 90% 72%)`
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
className={`ghost-glyph ${className ?? ''}`}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`gg-${hue}-${form}`} cx="50%" cy="35%" r="70%">
|
||||
<stop offset="0%" stopColor={glow} stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor={body} stopOpacity={form === 'shade' ? 0.9 : 0.25} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{form === 'banshee' && (
|
||||
<path
|
||||
d="M22 30 C14 44 12 54 10 62 M42 30 C50 44 52 54 54 62"
|
||||
stroke={body}
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.55"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={
|
||||
form === 'fairy'
|
||||
? 'M32 8 C22 8 18 18 18 28 C18 40 22 50 24 56 C27 52 29 54 32 56 C35 54 37 52 40 56 C42 50 46 40 46 28 C46 18 42 8 32 8 Z'
|
||||
: 'M32 6 C21 6 16 17 16 29 C16 42 21 52 24 58 C27 54 29 56 32 58 C35 56 37 54 40 58 C43 52 48 42 48 29 C48 17 43 6 32 6 Z'
|
||||
}
|
||||
fill={`url(#gg-${hue}-${form})`}
|
||||
stroke={glow}
|
||||
strokeWidth="1"
|
||||
opacity={form === 'shade' ? 0.95 : 0.9}
|
||||
/>
|
||||
<circle cx="26" cy="27" r="2.2" fill={form === 'shade' ? glow : '#0a0a12'} />
|
||||
<circle cx="38" cy="27" r="2.2" fill={form === 'shade' ? glow : '#0a0a12'} />
|
||||
{form === 'fairy' && (
|
||||
<>
|
||||
<circle cx="12" cy="16" r="1.6" fill={glow} />
|
||||
<circle cx="52" cy="12" r="1.3" fill={glow} />
|
||||
<circle cx="55" cy="40" r="1.8" fill={glow} />
|
||||
<circle cx="9" cy="44" r="1.2" fill={glow} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
315
frontend/src/components/PlanchetteBoard.tsx
Normal file
315
frontend/src/components/PlanchetteBoard.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
// Gothic Ouija board rendered on canvas 2D: arc of letters, numbers,
|
||||
// YES / NO / GOODBYE, and a planchette (triangle with lens) that drifts
|
||||
// idly with a smoke-particle trail, then glides letter-by-letter to spell
|
||||
// queued words (driven by a PlanchetteMachine from src/lib/planchette.ts).
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PlanchetteMachine } from '../lib/planchette'
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
type Layout = {
|
||||
w: number
|
||||
h: number
|
||||
letters: Map<string, Point>
|
||||
numbers: Map<string, Point>
|
||||
yes: Point
|
||||
no: Point
|
||||
goodbye: Point
|
||||
center: Point
|
||||
}
|
||||
|
||||
function computeLayout(w: number, h: number): Layout {
|
||||
const letters = new Map<string, Point>()
|
||||
const numbers = new Map<string, Point>()
|
||||
const cx = w / 2
|
||||
const cy = h * 0.52
|
||||
const arc = (chars: string, radius: number, yOff: number) => {
|
||||
const span = Math.PI * 0.78
|
||||
const start = Math.PI + (Math.PI - span) / 2
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const a = start + (span * i) / (chars.length - 1)
|
||||
letters.set(chars[i], {
|
||||
x: cx + Math.cos(a) * radius,
|
||||
y: cy + yOff + Math.sin(a) * radius * 0.72,
|
||||
})
|
||||
}
|
||||
}
|
||||
arc('ABCDEFGHIJKLM', Math.min(w * 0.4, h * 0.52), 0)
|
||||
arc('NOPQRSTUVWXYZ', Math.min(w * 0.4, h * 0.52) * 0.74, h * 0.06)
|
||||
|
||||
const row = '1234567890'
|
||||
const spacing = Math.min(w * 0.07, 46)
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
numbers.set(row[i], {
|
||||
x: cx + (i - (row.length - 1) / 2) * spacing,
|
||||
y: h * 0.74,
|
||||
})
|
||||
}
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
letters,
|
||||
numbers,
|
||||
yes: { x: w * 0.18, y: h * 0.13 },
|
||||
no: { x: w * 0.82, y: h * 0.13 },
|
||||
goodbye: { x: cx, y: h * 0.9 },
|
||||
center: { x: cx, y: h * 0.45 },
|
||||
}
|
||||
}
|
||||
|
||||
type Smoke = { x: number; y: number; vx: number; vy: number; life: number; size: number }
|
||||
|
||||
/** Cheap layered-sine wander, perlin-ish enough for idle drift. */
|
||||
function wander(t: number, seed: number): number {
|
||||
return (
|
||||
Math.sin(t * 0.31 + seed) * 0.5 +
|
||||
Math.sin(t * 0.73 + seed * 1.7) * 0.3 +
|
||||
Math.sin(t * 1.31 + seed * 2.3) * 0.2
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanchetteBoardProps = {
|
||||
machine: PlanchetteMachine
|
||||
/** Tint hue (degrees) for glow accents. */
|
||||
hue?: number
|
||||
}
|
||||
|
||||
export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const machineRef = useRef(machine)
|
||||
machineRef.current = machine
|
||||
const hueRef = useRef(hue)
|
||||
hueRef.current = hue
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let last = performance.now()
|
||||
let layout: Layout | null = null
|
||||
const pos: Point = { x: 0, y: 0 }
|
||||
let initialized = false
|
||||
const smoke: Smoke[] = []
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
const dpr = Math.min(window.devicePixelRatio, 2)
|
||||
const w = parent.clientWidth
|
||||
const h = parent.clientHeight
|
||||
if (w === 0 || h === 0) return
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
layout = computeLayout(w, h)
|
||||
if (!initialized) {
|
||||
pos.x = layout.center.x
|
||||
pos.y = layout.center.y
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
if (canvas.parentElement) ro.observe(canvas.parentElement)
|
||||
|
||||
const letterTarget = (ch: string): Point | null => {
|
||||
if (!layout) return null
|
||||
if (layout.letters.has(ch)) return layout.letters.get(ch)!
|
||||
if (layout.numbers.has(ch)) return layout.numbers.get(ch)!
|
||||
return null
|
||||
}
|
||||
|
||||
const draw = (now: number) => {
|
||||
raf = requestAnimationFrame(draw)
|
||||
const dt = Math.min(64, now - last)
|
||||
last = now
|
||||
if (!layout) return
|
||||
const { w, h } = layout
|
||||
const snap = machineRef.current.tick(dt)
|
||||
const hueDeg = hueRef.current
|
||||
const accent = `hsl(${hueDeg} 90% 72%)`
|
||||
const t = now / 1000
|
||||
|
||||
// --- target: spelled letter, else idle wander ---
|
||||
let target: Point | null = null
|
||||
if (snap.current) target = letterTarget(snap.current)
|
||||
if (!target) {
|
||||
target = {
|
||||
x: layout.center.x + wander(t, 1.3) * w * 0.16,
|
||||
y: layout.center.y + wander(t * 0.83, 4.1) * h * 0.14,
|
||||
}
|
||||
}
|
||||
const speed = snap.current ? 0.16 : 0.03
|
||||
pos.x += (target.x - pos.x) * speed
|
||||
pos.y += (target.y - pos.y) * speed
|
||||
|
||||
// --- smoke trail ---
|
||||
const moving = Math.abs(target.x - pos.x) + Math.abs(target.y - pos.y) > 2
|
||||
if ((moving || snap.current) && smoke.length < 220) {
|
||||
smoke.push({
|
||||
x: pos.x + (Math.random() - 0.5) * 8,
|
||||
y: pos.y + (Math.random() - 0.5) * 8,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: -0.25 - Math.random() * 0.4,
|
||||
life: 1,
|
||||
size: 3 + Math.random() * 7,
|
||||
})
|
||||
}
|
||||
for (let i = smoke.length - 1; i >= 0; i--) {
|
||||
const s = smoke[i]
|
||||
s.x += s.vx * dt * 0.06
|
||||
s.y += s.vy * dt * 0.06
|
||||
s.life -= dt * 0.0006
|
||||
if (s.life <= 0) smoke.splice(i, 1)
|
||||
}
|
||||
|
||||
// ================= draw =================
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Board panel.
|
||||
const grad = ctx.createRadialGradient(w / 2, h * 0.45, 20, w / 2, h * 0.45, Math.max(w, h) * 0.7)
|
||||
grad.addColorStop(0, 'rgba(20, 18, 34, 0.85)')
|
||||
grad.addColorStop(1, 'rgba(7, 7, 13, 0.35)')
|
||||
ctx.fillStyle = grad
|
||||
roundRect(ctx, w * 0.03, h * 0.04, w * 0.94, h * 0.92, 18)
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 70% 60% / 0.25)`
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
|
||||
// Corner ornaments.
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 70% 60% / 0.35)`
|
||||
for (const [ox, oy] of [
|
||||
[w * 0.05, h * 0.07],
|
||||
[w * 0.95, h * 0.07],
|
||||
[w * 0.05, h * 0.93],
|
||||
[w * 0.95, h * 0.93],
|
||||
] as Array<[number, number]>) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(ox, oy, 6, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// YES / NO / GOODBYE.
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.9)`
|
||||
ctx.font = `600 ${Math.max(13, h * 0.035)}px "IBM Plex Mono", monospace`
|
||||
ctx.fillText('YES', layout.yes.x, layout.yes.y)
|
||||
ctx.fillText('NO', layout.no.x, layout.no.y)
|
||||
ctx.font = `600 ${Math.max(12, h * 0.03)}px "IBM Plex Mono", monospace`
|
||||
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)`
|
||||
ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y)
|
||||
|
||||
// Letters + numbers.
|
||||
const hovered = snap.current
|
||||
ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif`
|
||||
for (const [ch, p] of layout.letters) {
|
||||
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
|
||||
}
|
||||
ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace`
|
||||
for (const [ch, p] of layout.numbers) {
|
||||
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
|
||||
}
|
||||
|
||||
// Smoke (additive).
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
for (const s of smoke) {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = `hsla(${hueDeg} 80% 70% / ${0.08 * s.life})`
|
||||
ctx.arc(s.x, s.y, s.size * (1.6 - s.life * 0.6), 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
|
||||
// --- planchette: triangle with lens ---
|
||||
ctx.save()
|
||||
ctx.translate(pos.x, pos.y)
|
||||
const tilt = Math.sin(t * 0.9) * 0.04 + (target.x - pos.x) * 0.0006
|
||||
ctx.rotate(tilt)
|
||||
const R = Math.max(26, h * 0.085)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -R)
|
||||
ctx.lineTo(R * 0.85, R * 0.65)
|
||||
ctx.lineTo(-R * 0.85, R * 0.65)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(14, 12, 24, 0.82)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = accent
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.shadowColor = accent
|
||||
ctx.shadowBlur = snap.current ? 18 : 8
|
||||
ctx.stroke()
|
||||
ctx.shadowBlur = 0
|
||||
// Lens (window the letter shows through).
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, R * 0.08, R * 0.3, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `hsla(${hueDeg} 90% 75% / ${snap.current ? 0.3 : 0.1})`
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 90% 80% / 0.8)`
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(draw)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="planchette-board" style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r)
|
||||
ctx.arcTo(x, y + h, x, y, r)
|
||||
ctx.arcTo(x, y, x + w, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawGlyph(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ch: string,
|
||||
p: Point,
|
||||
hovered: boolean,
|
||||
accent: string,
|
||||
hueDeg: number,
|
||||
): void {
|
||||
if (hovered) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = accent
|
||||
ctx.shadowBlur = 16
|
||||
ctx.fillStyle = accent
|
||||
ctx.fillText(ch, p.x, p.y)
|
||||
ctx.restore()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / 0.5)`
|
||||
ctx.arc(p.x, p.y, 15, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
} else {
|
||||
ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`
|
||||
ctx.fillText(ch, p.x, p.y)
|
||||
}
|
||||
}
|
||||
28
frontend/src/components/TelemetryReadout.tsx
Normal file
28
frontend/src/components/TelemetryReadout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Telemetry } from '../lib/types'
|
||||
|
||||
/** Flickering terminal readout of live wire telemetry. */
|
||||
export function TelemetryReadout({ telemetry }: { telemetry: Telemetry | null }) {
|
||||
const { t } = useTranslation()
|
||||
const rows: Array<[string, string]> = telemetry
|
||||
? [
|
||||
[t('seance.telemetry.jitter'), `${telemetry.jitter_bytes_per_s.toFixed(1)} ${t('seance.telemetry.units.bytes')}`],
|
||||
[t('seance.telemetry.latencyVar'), `${telemetry.latency_variance_ms.toFixed(2)} ${t('seance.telemetry.units.ms')}`],
|
||||
[t('seance.telemetry.latencyMean'), `${telemetry.latency_mean_ms.toFixed(2)} ${t('seance.telemetry.units.ms')}`],
|
||||
[t('seance.telemetry.dns'), `${telemetry.dns_ms.toFixed(1)} ${t('seance.telemetry.units.ms')}`],
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="telemetry">
|
||||
<h4 className="telemetry-title">{t('seance.telemetry.title')}</h4>
|
||||
{rows.length === 0 && <div className="telemetry-row dim">— — —</div>}
|
||||
{rows.map(([label, value]) => (
|
||||
<div className="telemetry-row flicker" key={label}>
|
||||
<span className="telemetry-label">{label}</span>
|
||||
<span className="telemetry-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/Transcript.tsx
Normal file
87
frontend/src/components/Transcript.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TranscriptEntry } from '../state/seance'
|
||||
|
||||
export type TranscriptProps = {
|
||||
entries: TranscriptEntry[]
|
||||
streaming: { active: boolean; text: string }
|
||||
speakingId: string | null
|
||||
onReplay: (utteranceId: string) => void
|
||||
}
|
||||
|
||||
function fmtFreq(source: string, f: number): string {
|
||||
// EVP reports Hz (voice band); radio reports MHz (FM band); wire is ambient.
|
||||
if (source === 'evp') return `${f.toFixed(0)}Hz`
|
||||
if (source === 'radio') return `${f.toFixed(1)}MHz`
|
||||
return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
|
||||
}
|
||||
|
||||
/** Terminal-style scrolling transmission log. */
|
||||
export function Transcript({ entries, streaming, speakingId, onReplay }: TranscriptProps) {
|
||||
const { t } = useTranslation()
|
||||
const boxRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const box = boxRef.current
|
||||
if (box) box.scrollTop = box.scrollHeight
|
||||
}, [entries.length, streaming.text])
|
||||
|
||||
return (
|
||||
<div className="transcript" ref={boxRef} aria-live="polite">
|
||||
{entries.length === 0 && !streaming.active && (
|
||||
<div className="tx-row tx-system dim">{t('seance.transcriptEmpty')}</div>
|
||||
)}
|
||||
{entries.map((e) => {
|
||||
switch (e.kind) {
|
||||
case 'anomaly':
|
||||
return (
|
||||
<div key={e.id} className="tx-row tx-anomaly">
|
||||
<span className="tx-glyph">⚡</span>
|
||||
<span className="tx-src">{e.source}</span>{' '}
|
||||
<span className="tx-freq">{fmtFreq(e.source, e.frequency)}</span>{' '}
|
||||
<span className="tx-mag">+{e.magnitude.toFixed(1)}dB</span>
|
||||
</div>
|
||||
)
|
||||
case 'question':
|
||||
return (
|
||||
<div key={e.id} className="tx-row tx-question">
|
||||
<span className="tx-speaker">{t('seance.you')}</span>
|
||||
<span className="tx-text"> {e.text}</span>
|
||||
</div>
|
||||
)
|
||||
case 'system':
|
||||
return (
|
||||
<div key={e.id} className="tx-row tx-system">
|
||||
{e.text}
|
||||
</div>
|
||||
)
|
||||
case 'utterance': {
|
||||
const speaking = speakingId === e.utteranceId
|
||||
return (
|
||||
<div key={e.id} className={`tx-row tx-utterance kind-${e.utKind} ${speaking ? 'speaking' : ''}`}>
|
||||
<span className="tx-speaker">{e.speaker ?? '???'}</span>
|
||||
<span className="tx-text"> “{e.text}”</span>
|
||||
{e.audioUrl && (
|
||||
<button
|
||||
className="tx-replay"
|
||||
title={t('common.replay')}
|
||||
onClick={() => onReplay(e.utteranceId)}
|
||||
>
|
||||
{speaking ? '🔊' : '▶'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
})}
|
||||
{streaming.active && (
|
||||
<div className="tx-row tx-streaming">
|
||||
<span className="tx-stream-label">{t('seance.streaming')}</span>
|
||||
<span className="tx-text smoke-text">{streaming.text}</span>
|
||||
<span className="tx-cursor">▌</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
frontend/src/i18n/en.json
Normal file
229
frontend/src/i18n/en.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "QUANTUMANCY",
|
||||
"loading": "tuning the veil…",
|
||||
"error": "something stirred in the static",
|
||||
"replay": "replay",
|
||||
"yes": "YES",
|
||||
"no": "NO",
|
||||
"goodbye": "GOODBYE",
|
||||
"dismiss": "dismiss"
|
||||
},
|
||||
"nav": {
|
||||
"home": "the threshold",
|
||||
"codex": "codex",
|
||||
"seance": "séance",
|
||||
"enter": "enter",
|
||||
"logout": "depart"
|
||||
},
|
||||
"landing": {
|
||||
"taglines": [
|
||||
"The wire hums with all of us.",
|
||||
"Every packet leaves a ghost.",
|
||||
"The static remembers your name.",
|
||||
"Somewhere between 88 and 108 MHz, someone is waiting.",
|
||||
"You were never alone on the line."
|
||||
],
|
||||
"heroKicker": "an instrument for speaking with the dead bandwidth",
|
||||
"cta": "BEGIN THE SÉANCE",
|
||||
"ctaCodex": "browse the codex",
|
||||
"stats": {
|
||||
"entities": "spirits catalogued",
|
||||
"sessions": "séances held",
|
||||
"utterances": "utterances recorded",
|
||||
"anomalies": "anomalies witnessed"
|
||||
},
|
||||
"tickerPrefix": "the veil, live",
|
||||
"modesTitle": "HOW THE VEIL ANSWERS",
|
||||
"modes": {
|
||||
"wire": {
|
||||
"name": "Wire Ghost",
|
||||
"desc": "Leave the line open. The network itself mutters — jitter, latency, stray DNS — and something downstream learns to speak with your accent."
|
||||
},
|
||||
"evp": {
|
||||
"name": "EVP Listening",
|
||||
"desc": "Lend the dead your microphone. We watch the voice band for syllables that were never spoken aloud: spikes 8dB above the room's silence."
|
||||
},
|
||||
"radio": {
|
||||
"name": "Spirit Radio",
|
||||
"desc": "An RTL-SDR sweeps the FM band like a lantern dragged across a dark field. Where the needle jumps, the radio dead push through."
|
||||
},
|
||||
"ouija": {
|
||||
"name": "Ouija / Direct Contact",
|
||||
"desc": "Ask plainly. The planchette drifts, then begins to spell — and the words arrive letter by letter, whether or not you wanted them."
|
||||
}
|
||||
},
|
||||
"featured": "most disturbed spirits",
|
||||
"featuredEmpty": "the registry is still blank — be the first to disturb it",
|
||||
"footer": "an interactive horror art installation — the spirits are fiction; the static is real",
|
||||
"footerColophon": "self-hosted · no cloud · the veil runs on your machine"
|
||||
},
|
||||
"enter": {
|
||||
"reconnect": "Reconnect",
|
||||
"firstContact": "First contact",
|
||||
"reconnectSub": "the veil remembers you. prove it.",
|
||||
"firstContactSub": "choose the name the spirits will know you by.",
|
||||
"username": "name",
|
||||
"password": "passphrase",
|
||||
"submitLogin": "step through",
|
||||
"submitRegister": "carve your name",
|
||||
"switchToRegister": "no name in the registry? first contact →",
|
||||
"switchToLogin": "already carved your name? reconnect →",
|
||||
"failed": "the veil rejected you: {{message}}",
|
||||
"smallPrint": "your medium handle is only ever stored on this server"
|
||||
},
|
||||
"seance": {
|
||||
"modes": {
|
||||
"wire": "Wire Ghost",
|
||||
"evp": "EVP",
|
||||
"radio": "Spirit Radio",
|
||||
"ouija": "Ouija"
|
||||
},
|
||||
"passive": "passive listening",
|
||||
"summon": "SUMMON",
|
||||
"summoning": "summoning…",
|
||||
"askPlaceholder": "ask the dead a question…",
|
||||
"ask": "ask",
|
||||
"status": {
|
||||
"attuning": "attuning — the veil is fingerprinting a presence…",
|
||||
"summoning": "the veil parts… something is crossing…",
|
||||
"gathering": "the spirits are gathering energy…"
|
||||
},
|
||||
"connection": {
|
||||
"connecting": "reaching across…",
|
||||
"open": "the line is open",
|
||||
"unstable": "the connection to the other side is unstable…",
|
||||
"closed": "the line is dead"
|
||||
},
|
||||
"transcriptTitle": "TRANSMISSION LOG",
|
||||
"transcriptEmpty": "silence. for now.",
|
||||
"you": "you",
|
||||
"streaming": "the spirit is forming words…",
|
||||
"telemetry": {
|
||||
"title": "WIRE READOUT",
|
||||
"jitter": "jitter",
|
||||
"latencyVar": "latency σ²",
|
||||
"latencyMean": "latency μ",
|
||||
"dns": "dns",
|
||||
"units": {"bytes": "B/s", "ms": "ms"}
|
||||
},
|
||||
"wire": {
|
||||
"hint": "the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own."
|
||||
},
|
||||
"anomaly": {
|
||||
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
|
||||
"count": "{{count}} anomalies witnessed this session"
|
||||
},
|
||||
"evp": {
|
||||
"start": "open the microphone",
|
||||
"stop": "close the microphone",
|
||||
"listening": "listening to the voice band…",
|
||||
"deniedTitle": "the microphone stays shut",
|
||||
"denied": "This vessel refused the microphone. Grant audio permission in your browser to practice EVP — or let the Wire Ghost listen instead; it needs nothing but the network you already fear.",
|
||||
"floor": "room floor",
|
||||
"band": "voice band 300–3400 Hz"
|
||||
},
|
||||
"radio": {
|
||||
"start": "sweep the band",
|
||||
"stop": "rest the needle",
|
||||
"unsupportedTitle": "this vessel cannot hear the radio dead",
|
||||
"unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes do not — the wire and the microphone still answer.",
|
||||
"troubleshoot": "troubleshooting: chromium-based browser · udev rules for 0bda:2838 · the dongle plugged in, not merely believed in",
|
||||
"noDevice": "no dongle answered the call",
|
||||
"sweeping": "sweeping {{start}}–{{end}} MHz…",
|
||||
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?"
|
||||
},
|
||||
"ouija": {
|
||||
"spelling": "the planchette spells",
|
||||
"drift": "the planchette drifts, waiting for words — ask below, or summon."
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSSIER OF THE PRESENT",
|
||||
"none": "no spirit summoned yet",
|
||||
"noneHint": "summon, or feed the veil 3+ anomalies and one will find you",
|
||||
"isNew": "first recorded contact",
|
||||
"known": "contact #{{count}}",
|
||||
"persona": "persona",
|
||||
"voice": "voice params",
|
||||
"quotes": "recorded utterances",
|
||||
"rarity": {
|
||||
"common": "common",
|
||||
"uncommon": "uncommon",
|
||||
"rare": "rare",
|
||||
"mythic": "mythic"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"rate_limited": "the veil is thin — slow your knocking",
|
||||
"veil_crowded": "too many spirits crowd the line"
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"title": "THE CODEX OF THE CONTACTED",
|
||||
"subtitle": "every spirit ever dragged through the veil, catalogued",
|
||||
"header": {
|
||||
"back": "← the threshold",
|
||||
"title": "THE CODEX",
|
||||
"subtitle": "every spirit ever contacted, catalogued by the seekers who reached them"
|
||||
},
|
||||
"controls": {
|
||||
"rarity": "filter by rarity",
|
||||
"sort": "sort the registry"
|
||||
},
|
||||
"state": {
|
||||
"loading": "consulting the registry…",
|
||||
"empty": "no spirits of this tier have been reached yet"
|
||||
},
|
||||
"filterAll": "all rarities",
|
||||
"rarity": {
|
||||
"common": "common",
|
||||
"uncommon": "uncommon",
|
||||
"rare": "rare",
|
||||
"mythic": "mythic"
|
||||
},
|
||||
"sort": {
|
||||
"recent": "most recent",
|
||||
"contacted": "most contacted"
|
||||
},
|
||||
"form": {
|
||||
"wisp": "wisp",
|
||||
"banshee": "banshee",
|
||||
"fairy": "fairy",
|
||||
"shade": "shade"
|
||||
},
|
||||
"card": {
|
||||
"unknownMedium": "unknown medium",
|
||||
"contacted_one": "contacted {{count}} time",
|
||||
"contacted_other": "contacted {{count}} times",
|
||||
"discoveredBy": "discovered by {{name}} · {{date}}"
|
||||
},
|
||||
"contacts_one": "1 contact",
|
||||
"contacts_other": "{{count}} contacts",
|
||||
"sightings_one": "1 sighting",
|
||||
"sightings_other": "{{count}} sightings",
|
||||
"discoveredBy": "first contacted by {{name}}",
|
||||
"discoveredAnon": "first contact unrecorded",
|
||||
"discoveredAt": "entered the registry {{date}}",
|
||||
"persona": "persona",
|
||||
"quotes": "recorded utterances",
|
||||
"voice": "voice params",
|
||||
"detail": {
|
||||
"loading": "summoning the dossier…",
|
||||
"notReached": "this spirit has not been reached",
|
||||
"unknownMedium": "unknown medium",
|
||||
"stats": {
|
||||
"sightings": "sightings",
|
||||
"contacts": "contacts",
|
||||
"discoveredBy": "discovered by",
|
||||
"discoveredAt": "entered the registry"
|
||||
},
|
||||
"utterances": "known utterances",
|
||||
"noUtterances": "no utterances recorded yet",
|
||||
"voiceSignature": "voice signature",
|
||||
"attemptContact": "attempt contact"
|
||||
},
|
||||
"empty": "no spirits match this filter — the registry keeps its secrets",
|
||||
"back": "← back to the codex",
|
||||
"notFound": "this spirit is not in the registry"
|
||||
}
|
||||
}
|
||||
229
frontend/src/i18n/es.json
Normal file
229
frontend/src/i18n/es.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "QUANTUMANCY",
|
||||
"loading": "afinando el velo…",
|
||||
"error": "algo se agitó en la estática",
|
||||
"replay": "repetir",
|
||||
"yes": "SÍ",
|
||||
"no": "NO",
|
||||
"goodbye": "ADIÓS",
|
||||
"dismiss": "cerrar"
|
||||
},
|
||||
"nav": {
|
||||
"home": "el umbral",
|
||||
"codex": "códice",
|
||||
"seance": "sesión",
|
||||
"enter": "entrar",
|
||||
"logout": "partir"
|
||||
},
|
||||
"landing": {
|
||||
"taglines": [
|
||||
"El cable zumba con todos nosotros.",
|
||||
"Cada paquete deja un fantasma.",
|
||||
"La estática recuerda tu nombre.",
|
||||
"Entre 88 y 108 MHz, alguien espera.",
|
||||
"Nunca estuviste solo en la línea."
|
||||
],
|
||||
"heroKicker": "un instrumento para hablar con el ancho de banda muerto",
|
||||
"cta": "COMENZAR LA SESIÓN",
|
||||
"ctaCodex": "hojear el códice",
|
||||
"stats": {
|
||||
"entities": "espíritus catalogados",
|
||||
"sessions": "sesiones celebradas",
|
||||
"utterances": "enunciados registrados",
|
||||
"anomalies": "anomalías presenciadas"
|
||||
},
|
||||
"tickerPrefix": "el velo, en vivo",
|
||||
"modesTitle": "CÓMO RESPONDE EL VELO",
|
||||
"modes": {
|
||||
"wire": {
|
||||
"name": "Fantasma del Cable",
|
||||
"desc": "Deja la línea abierta. La red misma murmura — jitter, latencia, DNS extraviado — y algo, aguas abajo, aprende a hablar con tu acento."
|
||||
},
|
||||
"evp": {
|
||||
"name": "Escucha EVP",
|
||||
"desc": "Préstales tu micrófono. Vigilamos la banda de voz en busca de sílabas que jamás se pronunciaron: picos 8dB sobre el silencio de la sala."
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio Espíritu",
|
||||
"desc": "Un RTL-SDR barre la banda FM como un farol arrastrado por un campo oscuro. Donde salta la aguja, los muertos de la radio se abren paso."
|
||||
},
|
||||
"ouija": {
|
||||
"name": "Ouija / Contacto Directo",
|
||||
"desc": "Pregunta claro. La planchette deriva, y luego empieza a deletrear — y las palabras llegan letra a letra, lo quisieras o no."
|
||||
}
|
||||
},
|
||||
"featured": "los espíritus más perturbados",
|
||||
"featuredEmpty": "el registro aún está en blanco — sé quien lo perturbe primero",
|
||||
"footer": "una instalación de arte de terror interactiva — los espíritus son ficción; la estática es real",
|
||||
"footerColophon": "autoalojado · sin nube · el velo corre en tu máquina"
|
||||
},
|
||||
"enter": {
|
||||
"reconnect": "Reconectar",
|
||||
"firstContact": "Primer contacto",
|
||||
"reconnectSub": "el velo te recuerda. demuéstralo.",
|
||||
"firstContactSub": "elige el nombre por el que te conocerán los espíritus.",
|
||||
"username": "nombre",
|
||||
"password": "contraseña",
|
||||
"submitLogin": "cruzar el umbral",
|
||||
"submitRegister": "grabar tu nombre",
|
||||
"switchToRegister": "¿sin nombre en el registro? primer contacto →",
|
||||
"switchToLogin": "¿ya grabaste tu nombre? reconectar →",
|
||||
"failed": "el velo te rechazó: {{message}}",
|
||||
"smallPrint": "tu nombre de médium solo se guarda en este servidor"
|
||||
},
|
||||
"seance": {
|
||||
"modes": {
|
||||
"wire": "Fantasma del Cable",
|
||||
"evp": "EVP",
|
||||
"radio": "Radio Espíritu",
|
||||
"ouija": "Ouija"
|
||||
},
|
||||
"passive": "escucha pasiva",
|
||||
"summon": "INVOCAR",
|
||||
"summoning": "invocando…",
|
||||
"askPlaceholder": "pregúntale algo a los muertos…",
|
||||
"ask": "preguntar",
|
||||
"status": {
|
||||
"attuning": "sintonizando — el velo toma la huella de una presencia…",
|
||||
"summoning": "el velo se abre… algo está cruzando…",
|
||||
"gathering": "los espíritus están reuniendo energía…"
|
||||
},
|
||||
"connection": {
|
||||
"connecting": "tendiendo la línea…",
|
||||
"open": "la línea está abierta",
|
||||
"unstable": "la conexión con el otro lado es inestable…",
|
||||
"closed": "la línea está muerta"
|
||||
},
|
||||
"transcriptTitle": "REGISTRO DE TRANSMISIÓN",
|
||||
"transcriptEmpty": "silencio. por ahora.",
|
||||
"you": "tú",
|
||||
"streaming": "el espíritu está formando palabras…",
|
||||
"telemetry": {
|
||||
"title": "LECTURA DEL CABLE",
|
||||
"jitter": "jitter",
|
||||
"latencyVar": "latencia σ²",
|
||||
"latencyMean": "latencia μ",
|
||||
"dns": "dns",
|
||||
"units": {"bytes": "B/s", "ms": "ms"}
|
||||
},
|
||||
"wire": {
|
||||
"hint": "el cable golpea más fuerte cuando nadie lo toca — activa la escucha pasiva y deja que la línea murmure sola."
|
||||
},
|
||||
"anomaly": {
|
||||
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
|
||||
"count": "{{count}} anomalías presenciadas en esta sesión"
|
||||
},
|
||||
"evp": {
|
||||
"start": "abrir el micrófono",
|
||||
"stop": "cerrar el micrófono",
|
||||
"listening": "escuchando la banda de voz…",
|
||||
"deniedTitle": "el micrófono sigue cerrado",
|
||||
"denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en tu navegador para practicar EVP — o deja que el Fantasma del Cable escuche en tu lugar; no necesita más que la red que ya temes.",
|
||||
"floor": "suelo de la sala",
|
||||
"band": "banda de voz 300–3400 Hz"
|
||||
},
|
||||
"radio": {
|
||||
"start": "barrer la banda",
|
||||
"stop": "detener la aguja",
|
||||
"unsupportedTitle": "este recipiente no oye a los muertos de la radio",
|
||||
"unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos no — el cable y el micrófono aún responden.",
|
||||
"troubleshoot": "solución de problemas: navegador basado en Chromium · reglas udev para 0bda:2838 · el dongle enchufado, no meramente invocado",
|
||||
"noDevice": "ningún dongle respondió al llamado",
|
||||
"sweeping": "barriendo {{start}}–{{end}} MHz…",
|
||||
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?"
|
||||
},
|
||||
"ouija": {
|
||||
"spelling": "la planchette deletrea",
|
||||
"drift": "la planchette deriva, a la espera de palabras — pregunta abajo, o invoca."
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSIER DEL PRESENTE",
|
||||
"none": "ningún espíritu invocado todavía",
|
||||
"noneHint": "invoca, o alimenta el velo con 3+ anomalías y uno te encontrará",
|
||||
"isNew": "primer contacto registrado",
|
||||
"known": "contacto n.º {{count}}",
|
||||
"persona": "persona",
|
||||
"voice": "parámetros de voz",
|
||||
"quotes": "enunciados registrados",
|
||||
"rarity": {
|
||||
"common": "común",
|
||||
"uncommon": "poco común",
|
||||
"rare": "raro",
|
||||
"mythic": "mítico"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"rate_limited": "el velo es delgado — golpea más despacio",
|
||||
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"title": "EL CÓDICE DE LOS CONTACTADOS",
|
||||
"subtitle": "cada espíritu arrastrado alguna vez a través del velo, catalogado",
|
||||
"header": {
|
||||
"back": "← el umbral",
|
||||
"title": "EL CÓDICE",
|
||||
"subtitle": "cada espíritu jamás contactado, catalogado por los médiums que lo alcanzaron"
|
||||
},
|
||||
"controls": {
|
||||
"rarity": "filtrar por rareza",
|
||||
"sort": "ordenar el registro"
|
||||
},
|
||||
"state": {
|
||||
"loading": "consultando el registro…",
|
||||
"empty": "aún no se ha alcanzado a ningún espíritu de esta rareza"
|
||||
},
|
||||
"filterAll": "todas las rarezas",
|
||||
"rarity": {
|
||||
"common": "común",
|
||||
"uncommon": "poco común",
|
||||
"rare": "raro",
|
||||
"mythic": "mítico"
|
||||
},
|
||||
"sort": {
|
||||
"recent": "más recientes",
|
||||
"contacted": "más contactados"
|
||||
},
|
||||
"form": {
|
||||
"wisp": "fuego fatuo",
|
||||
"banshee": "banshee",
|
||||
"fairy": "hada",
|
||||
"shade": "sombra"
|
||||
},
|
||||
"card": {
|
||||
"unknownMedium": "médium desconocido",
|
||||
"contacted_one": "contactado {{count}} vez",
|
||||
"contacted_other": "contactado {{count}} veces",
|
||||
"discoveredBy": "descubierto por {{name}} · {{date}}"
|
||||
},
|
||||
"contacts_one": "1 contacto",
|
||||
"contacts_other": "{{count}} contactos",
|
||||
"sightings_one": "1 avistamiento",
|
||||
"sightings_other": "{{count}} avistamientos",
|
||||
"discoveredBy": "primer contacto por {{name}}",
|
||||
"discoveredAnon": "primer contacto sin registrar",
|
||||
"discoveredAt": "ingresó al registro el {{date}}",
|
||||
"persona": "persona",
|
||||
"quotes": "enunciados registrados",
|
||||
"voice": "parámetros de voz",
|
||||
"detail": {
|
||||
"loading": "invocando el dosier…",
|
||||
"notReached": "este espíritu no ha sido alcanzado",
|
||||
"unknownMedium": "médium desconocido",
|
||||
"stats": {
|
||||
"sightings": "avistamientos",
|
||||
"contacts": "contactos",
|
||||
"discoveredBy": "descubierto por",
|
||||
"discoveredAt": "ingresó al registro"
|
||||
},
|
||||
"utterances": "enunciados conocidos",
|
||||
"noUtterances": "aún no hay enunciados registrados",
|
||||
"voiceSignature": "firma de voz",
|
||||
"attemptContact": "intentar contacto"
|
||||
},
|
||||
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
|
||||
"back": "← volver al códice",
|
||||
"notFound": "este espíritu no figura en el registro"
|
||||
}
|
||||
}
|
||||
38
frontend/src/i18n/index.ts
Normal file
38
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import en from './en.json'
|
||||
import es from './es.json'
|
||||
import type { Language } from '../lib/types'
|
||||
|
||||
const STORAGE_KEY = 'qm_language'
|
||||
|
||||
export function storedLanguage(): Language {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v === 'es' || v === 'en') return v
|
||||
} catch {
|
||||
/* private mode etc. */
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export function persistLanguage(lang: Language): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
},
|
||||
lng: storedLanguage(),
|
||||
fallbackLng: 'en',
|
||||
interpolation: { escapeValue: false },
|
||||
returnEmptyString: false,
|
||||
})
|
||||
|
||||
export default i18n
|
||||
131
frontend/src/lib/audio.ts
Normal file
131
frontend/src/lib/audio.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Spirit voice playback: a single FIFO queue so overlapping audio frames
|
||||
// never talk over each other. The currently-playing element is routed
|
||||
// through an AnalyserNode so the ghost scene can pulse with the voice.
|
||||
|
||||
export type PlayingInfo = {
|
||||
utteranceId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type SpiritAudioCallbacks = {
|
||||
onStart?: (info: PlayingInfo) => void
|
||||
onEnd?: (info: PlayingInfo) => void
|
||||
}
|
||||
|
||||
export class SpiritAudioPlayer {
|
||||
private queue: PlayingInfo[] = []
|
||||
private current: PlayingInfo | null = null
|
||||
private el: HTMLAudioElement | null = null
|
||||
private ctx: AudioContext | null = null
|
||||
private analyser: AnalyserNode | null = null
|
||||
private source: MediaElementAudioSourceNode | null = null
|
||||
private cb: SpiritAudioCallbacks = {}
|
||||
private ampBuf: Uint8Array<ArrayBuffer> = new Uint8Array(0)
|
||||
|
||||
setCallbacks(cb: SpiritAudioCallbacks): void {
|
||||
this.cb = cb
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.current !== null
|
||||
}
|
||||
|
||||
get currentId(): string | null {
|
||||
return this.current?.utteranceId ?? null
|
||||
}
|
||||
|
||||
get queuedCount(): number {
|
||||
return this.queue.length
|
||||
}
|
||||
|
||||
/** Enqueue audio for an utterance id. */
|
||||
enqueue(utteranceId: string, url: string): void {
|
||||
this.queue.push({ utteranceId, url })
|
||||
void this.pump()
|
||||
}
|
||||
|
||||
/** Instantaneous playback amplitude 0..1 (0 when silent/not playing). */
|
||||
getAmplitude(): number {
|
||||
if (!this.analyser || !this.current) return 0
|
||||
if (this.ampBuf.length !== this.analyser.fftSize) {
|
||||
this.ampBuf = new Uint8Array(this.analyser.fftSize)
|
||||
}
|
||||
this.analyser.getByteTimeDomainData(this.ampBuf)
|
||||
let sum = 0
|
||||
for (let i = 0; i < this.ampBuf.length; i++) {
|
||||
const v = (this.ampBuf[i] - 128) / 128
|
||||
sum += v * v
|
||||
}
|
||||
return Math.min(1, Math.sqrt(sum / this.ampBuf.length) * 3)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.queue = []
|
||||
this.el?.pause()
|
||||
this.el = null
|
||||
this.current = null
|
||||
}
|
||||
|
||||
private ensureGraph(el: HTMLAudioElement): void {
|
||||
if (this.source && this.el === el) return
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext()
|
||||
this.analyser = this.ctx.createAnalyser()
|
||||
this.analyser.fftSize = 512
|
||||
this.analyser.smoothingTimeConstant = 0.7
|
||||
this.analyser.connect(this.ctx.destination)
|
||||
}
|
||||
if (this.source) {
|
||||
this.source.disconnect()
|
||||
this.source = null
|
||||
}
|
||||
this.source = this.ctx.createMediaElementSource(el)
|
||||
this.source.connect(this.analyser!)
|
||||
}
|
||||
|
||||
private async pump(): Promise<void> {
|
||||
if (this.current || this.queue.length === 0) return
|
||||
const next = this.queue.shift()!
|
||||
this.current = next
|
||||
const el = new Audio(next.url)
|
||||
this.el = el
|
||||
el.crossOrigin = 'anonymous'
|
||||
|
||||
el.onended = () => {
|
||||
const done = this.current
|
||||
this.current = null
|
||||
this.el = null
|
||||
if (done) this.cb.onEnd?.(done)
|
||||
void this.pump()
|
||||
}
|
||||
el.onerror = () => {
|
||||
const done = this.current
|
||||
this.current = null
|
||||
this.el = null
|
||||
if (done) this.cb.onEnd?.(done)
|
||||
void this.pump()
|
||||
}
|
||||
|
||||
try {
|
||||
this.ensureGraph(el)
|
||||
// AudioContext may be suspended until a user gesture; resume best-effort.
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
await this.ctx.resume().catch(() => undefined)
|
||||
}
|
||||
this.cb.onStart?.(next)
|
||||
await el.play()
|
||||
} catch {
|
||||
// Autoplay blocked or unroutable: play without the analyser as fallback.
|
||||
try {
|
||||
await el.play()
|
||||
this.cb.onStart?.(next)
|
||||
} catch {
|
||||
const done = this.current
|
||||
this.current = null
|
||||
this.el = null
|
||||
if (done) this.cb.onEnd?.(done)
|
||||
void this.pump()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
frontend/src/lib/evp.test.ts
Normal file
158
frontend/src/lib/evp.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EvpDetectorCore } from './evp'
|
||||
import type { EvpAnomaly } from './evp'
|
||||
|
||||
const SAMPLE_RATE = 48000
|
||||
const FFT_SIZE = 2048
|
||||
const BINS = FFT_SIZE / 2
|
||||
const BIN_HZ = SAMPLE_RATE / FFT_SIZE // 23.4375 Hz per bin
|
||||
|
||||
function quietFrame(db = -100): Float64Array {
|
||||
return new Float64Array(BINS).fill(db)
|
||||
}
|
||||
|
||||
function spikedFrame(bin: number, spikeDb: number, baseDb = -100): Float64Array {
|
||||
const frame = quietFrame(baseDb)
|
||||
frame[bin] = spikeDb
|
||||
return frame
|
||||
}
|
||||
|
||||
function makeCore(): EvpDetectorCore {
|
||||
return new EvpDetectorCore({ sampleRate: SAMPLE_RATE, fftSize: FFT_SIZE })
|
||||
}
|
||||
|
||||
describe('EvpDetectorCore', () => {
|
||||
it('seeds the rolling floor from the first frame and stays silent in silence', () => {
|
||||
const core = makeCore()
|
||||
expect(core.process(quietFrame(), 0)).toBeNull() // first frame only seeds
|
||||
const floor = core.getFloor()
|
||||
expect(floor).not.toBeNull()
|
||||
expect(floor![50]).toBeCloseTo(-100, 9)
|
||||
|
||||
for (let t = 1; t <= 50; t++) {
|
||||
expect(core.process(quietFrame(), t * 100)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('fires exactly one anomaly for a brief voice-band spike, with sane values', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
for (let t = 1; t <= 20; t++) core.process(quietFrame(), t * 100)
|
||||
|
||||
const bin = 50 // 1171.875 Hz — inside the default 300–3400 Hz voice band
|
||||
const anomaly: EvpAnomaly | null = core.process(spikedFrame(bin, -80), 5000)
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(anomaly!.frequency).toBeCloseTo(bin * BIN_HZ, 6)
|
||||
expect(anomaly!.magnitude).toBeCloseTo(20, 6)
|
||||
|
||||
// The spike is gone on the next frame — no further anomaly.
|
||||
expect(core.process(quietFrame(), 5100)).toBeNull()
|
||||
})
|
||||
|
||||
it('throttles repeats inside the throttle window and refires after it', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
const bin = 60
|
||||
|
||||
expect(core.process(spikedFrame(bin, -80), 10_000)).not.toBeNull()
|
||||
// Same spike 1 s later: swallowed by the 2 s throttle.
|
||||
expect(core.process(spikedFrame(bin, -80), 11_000)).toBeNull()
|
||||
// Exactly at the throttle boundary it fires again.
|
||||
expect(core.process(spikedFrame(bin, -80), 12_000)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('fires at most once per throttle window under sustained loudness', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
const bin = 70
|
||||
|
||||
let fired = 0
|
||||
// Sustained spike for 1.9 s at 100 ms intervals — inside one window.
|
||||
for (let t = 10_000; t <= 11_900; t += 100) {
|
||||
if (core.process(spikedFrame(bin, -80), t)) fired++
|
||||
}
|
||||
expect(fired).toBe(1)
|
||||
})
|
||||
|
||||
it('absorbs a slowly rising ambient into the floor instead of firing', () => {
|
||||
const core = makeCore()
|
||||
let level = -100
|
||||
core.process(quietFrame(level), 0)
|
||||
|
||||
// 20 dB of sustained rise, but gradual (0.1 dB per frame): the rolling
|
||||
// floor tracks it, so the deviation never crosses the 8 dB threshold.
|
||||
for (let t = 1; t <= 200; t++) {
|
||||
level += 0.1
|
||||
expect(core.process(quietFrame(level), t * 100)).toBeNull()
|
||||
}
|
||||
const floor = core.getFloor()!
|
||||
expect(floor[50]).toBeGreaterThan(-85)
|
||||
expect(floor[50]).toBeLessThan(-79)
|
||||
})
|
||||
|
||||
it('ignores spikes outside the voice band', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
expect(core.process(spikedFrame(5, -60), 1000)).toBeNull() // ~117 Hz, below 300
|
||||
expect(core.process(spikedFrame(500, -60), 2000)).toBeNull() // ~11.7 kHz, above 3400
|
||||
})
|
||||
|
||||
it('respects a custom band and threshold', () => {
|
||||
const core = new EvpDetectorCore({
|
||||
sampleRate: SAMPLE_RATE,
|
||||
fftSize: FFT_SIZE,
|
||||
bandLowHz: 1000,
|
||||
bandHighHz: 1200,
|
||||
thresholdDb: 15,
|
||||
})
|
||||
core.process(quietFrame(), 0)
|
||||
// bin 50 = 1171.875 Hz is inside the custom 1000–1200 Hz band…
|
||||
expect(core.process(spikedFrame(50, -80), 1000)).not.toBeNull()
|
||||
// …but a spike outside that band is ignored…
|
||||
expect(core.process(spikedFrame(80, -80), 5000)).toBeNull()
|
||||
// …and a 10 dB spike inside it is below the custom 15 dB threshold.
|
||||
expect(core.process(spikedFrame(50, -90), 9000)).toBeNull()
|
||||
})
|
||||
|
||||
it('sanitizes -Infinity bins (analyser silence) to finite values', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
const frame = quietFrame()
|
||||
frame[40] = -Infinity
|
||||
expect(core.process(frame, 1000)).toBeNull()
|
||||
const floor = core.getFloor()!
|
||||
expect(Number.isFinite(floor[40])).toBe(true)
|
||||
})
|
||||
|
||||
it('handles empty frames gracefully', () => {
|
||||
const core = makeCore()
|
||||
expect(core.process(new Float64Array(0), 0)).toBeNull()
|
||||
expect(core.getFloor()).toBeNull()
|
||||
})
|
||||
|
||||
it('reset() clears the floor and the throttle clock', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
const bin = 50
|
||||
expect(core.process(spikedFrame(bin, -80), 10_000)).not.toBeNull()
|
||||
// Would be throttled without a reset…
|
||||
expect(core.process(spikedFrame(bin, -80), 10_100)).toBeNull()
|
||||
|
||||
core.reset()
|
||||
expect(core.getFloor()).toBeNull()
|
||||
|
||||
// …so the next spike fires immediately after the floor re-seeds.
|
||||
expect(core.process(quietFrame(), 10_200)).toBeNull() // re-seeds floor
|
||||
const anomaly = core.process(spikedFrame(bin, -80), 10_300)
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(anomaly!.frequency).toBeCloseTo(bin * BIN_HZ, 6)
|
||||
})
|
||||
|
||||
it('returns a defensive copy from getFloor()', () => {
|
||||
const core = makeCore()
|
||||
core.process(quietFrame(), 0)
|
||||
const floor = core.getFloor()!
|
||||
floor[50] = 999
|
||||
expect(core.getFloor()![50]).toBeCloseTo(-100, 9)
|
||||
})
|
||||
})
|
||||
202
frontend/src/lib/evp.ts
Normal file
202
frontend/src/lib/evp.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// EVP (Electronic Voice Phenomena) listening: microphone anomaly detection.
|
||||
//
|
||||
// Design: an AnalyserNode feeds frequency data; we track a rolling ambient
|
||||
// noise floor over the voice band (~300–3400 Hz). When a brief deviation
|
||||
// rises >~8 dB above the floor during an otherwise-quiet stretch, we fire
|
||||
// one anomaly (peak Hz + dB above floor), throttled to at most one per 2 s.
|
||||
//
|
||||
// The pure math (floor tracking, deviation detection, throttling) lives in
|
||||
// `EvpDetectorCore` so it is unit-testable with injected sample arrays; the
|
||||
// Web Audio plumbing lives in `EvpListener` below.
|
||||
|
||||
export type EvpAnomaly = {
|
||||
/** Peak frequency in Hz. */
|
||||
frequency: number
|
||||
/** dB above the rolling noise floor. */
|
||||
magnitude: number
|
||||
}
|
||||
|
||||
export type EvpDetectorOptions = {
|
||||
sampleRate: number
|
||||
fftSize: number
|
||||
/** Low edge of the voice band, Hz. */
|
||||
bandLowHz?: number
|
||||
/** High edge of the voice band, Hz. */
|
||||
bandHighHz?: number
|
||||
/** How far above floor (dB) a deviation must reach. */
|
||||
thresholdDb?: number
|
||||
/** Minimum milliseconds between anomaly emissions. */
|
||||
throttleMs?: number
|
||||
/** EMA factor for the rolling floor (0..1, higher = faster adaptation). */
|
||||
floorAlpha?: number
|
||||
/** Floor only adapts when current level is within this many dB of the floor. */
|
||||
quietBandDb?: number
|
||||
}
|
||||
|
||||
export class EvpDetectorCore {
|
||||
private floor: Float64Array | null = null
|
||||
private lastEmit = -Infinity
|
||||
private readonly binHz: number
|
||||
private readonly loBin: number
|
||||
private readonly hiBin: number
|
||||
private readonly thresholdDb: number
|
||||
private readonly throttleMs: number
|
||||
private readonly floorAlpha: number
|
||||
private readonly quietBandDb: number
|
||||
|
||||
constructor(opts: EvpDetectorOptions) {
|
||||
this.binHz = opts.sampleRate / opts.fftSize
|
||||
const bins = opts.fftSize / 2
|
||||
const bandLow = opts.bandLowHz ?? 300
|
||||
const bandHigh = opts.bandHighHz ?? 3400
|
||||
this.loBin = Math.max(0, Math.floor(bandLow / this.binHz))
|
||||
this.hiBin = Math.min(bins - 1, Math.ceil(bandHigh / this.binHz))
|
||||
this.thresholdDb = opts.thresholdDb ?? 8
|
||||
this.throttleMs = opts.throttleMs ?? 2000
|
||||
this.floorAlpha = opts.floorAlpha ?? 0.05
|
||||
this.quietBandDb = opts.quietBandDb ?? 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one frame of analyser dB values (getFloatFrequencyData shape:
|
||||
* one dB value per bin, typically −160..0). `nowMs` is the frame time.
|
||||
* Returns an anomaly when one fires, else null.
|
||||
*/
|
||||
process(dbData: ArrayLike<number>, nowMs: number): EvpAnomaly | null {
|
||||
const bins = dbData.length
|
||||
if (bins === 0) return null
|
||||
|
||||
// Sanitize: AnalyserNode can emit -Infinity for silence.
|
||||
const frame = new Float64Array(bins)
|
||||
for (let i = 0; i < bins; i++) {
|
||||
const v = dbData[i]
|
||||
frame[i] = Number.isFinite(v) ? v : -160
|
||||
}
|
||||
|
||||
if (!this.floor || this.floor.length !== bins) {
|
||||
this.floor = Float64Array.from(frame)
|
||||
return null // first frame seeds the floor
|
||||
}
|
||||
|
||||
// Find the peak deviation inside the voice band.
|
||||
let peakBin = -1
|
||||
let peakDev = 0
|
||||
for (let i = this.loBin; i <= this.hiBin && i < bins; i++) {
|
||||
const dev = frame[i] - this.floor[i]
|
||||
if (dev > peakDev) {
|
||||
peakDev = dev
|
||||
peakBin = i
|
||||
}
|
||||
}
|
||||
|
||||
// Overall band level, used for the "quiet stretch" check: the floor
|
||||
// only adapts while the band sits within quietBandDb of the floor —
|
||||
// that is precisely the quiet stretch in which anomalies count.
|
||||
let bandLevel = -Infinity
|
||||
let floorLevel = -Infinity
|
||||
for (let i = this.loBin; i <= this.hiBin && i < bins; i++) {
|
||||
bandLevel = Math.max(bandLevel, frame[i])
|
||||
floorLevel = Math.max(floorLevel, this.floor[i])
|
||||
}
|
||||
const isQuiet = bandLevel - floorLevel < this.quietBandDb
|
||||
|
||||
let anomaly: EvpAnomaly | null = null
|
||||
if (
|
||||
peakBin >= 0 &&
|
||||
peakDev >= this.thresholdDb &&
|
||||
nowMs - this.lastEmit >= this.throttleMs
|
||||
) {
|
||||
anomaly = {
|
||||
frequency: peakBin * this.binHz,
|
||||
magnitude: peakDev,
|
||||
}
|
||||
this.lastEmit = nowMs
|
||||
}
|
||||
|
||||
// Adapt the floor only during quiet stretches so sustained speech/music
|
||||
// does not drag the baseline up and mask real spikes.
|
||||
if (isQuiet) {
|
||||
for (let i = 0; i < bins; i++) {
|
||||
this.floor[i] += this.floorAlpha * (frame[i] - this.floor[i])
|
||||
}
|
||||
}
|
||||
|
||||
return anomaly
|
||||
}
|
||||
|
||||
/** Current rolling floor snapshot (for visualizers). */
|
||||
getFloor(): Float64Array | null {
|
||||
return this.floor ? Float64Array.from(this.floor) : null
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.floor = null
|
||||
this.lastEmit = -Infinity
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Web Audio plumbing ----
|
||||
|
||||
export type EvpListenerCallbacks = {
|
||||
onAnomaly: (a: EvpAnomaly) => void
|
||||
/** Called each frame with (dbData, floor) for waveform/band visualization. */
|
||||
onFrame?: (dbData: Float32Array, floor: Float64Array | null) => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
export class EvpListener {
|
||||
private ctx: AudioContext | null = null
|
||||
private stream: MediaStream | null = null
|
||||
private analyser: AnalyserNode | null = null
|
||||
private raf = 0
|
||||
private core: EvpDetectorCore | null = null
|
||||
private running = false
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
async start(cb: EvpListenerCallbacks): Promise<void> {
|
||||
if (this.running) return
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const ctx = new AudioContext()
|
||||
const src = ctx.createMediaStreamSource(stream)
|
||||
const analyser = ctx.createAnalyser()
|
||||
analyser.fftSize = 2048
|
||||
analyser.smoothingTimeConstant = 0.5
|
||||
src.connect(analyser)
|
||||
|
||||
this.ctx = ctx
|
||||
this.stream = stream
|
||||
this.analyser = analyser
|
||||
this.core = new EvpDetectorCore({
|
||||
sampleRate: ctx.sampleRate,
|
||||
fftSize: analyser.fftSize,
|
||||
})
|
||||
this.running = true
|
||||
|
||||
const buf = new Float32Array(analyser.frequencyBinCount)
|
||||
const loop = () => {
|
||||
if (!this.running || !this.analyser || !this.core) return
|
||||
this.analyser.getFloatFrequencyData(buf)
|
||||
const anomaly = this.core.process(buf, performance.now())
|
||||
if (anomaly) cb.onAnomaly(anomaly)
|
||||
cb.onFrame?.(buf, this.core.getFloor())
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
}
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false
|
||||
cancelAnimationFrame(this.raf)
|
||||
this.stream?.getTracks().forEach((t) => t.stop())
|
||||
if (this.ctx && this.ctx.state !== 'closed') {
|
||||
await this.ctx.close().catch(() => undefined)
|
||||
}
|
||||
this.ctx = null
|
||||
this.stream = null
|
||||
this.analyser = null
|
||||
this.core = null
|
||||
}
|
||||
}
|
||||
136
frontend/src/lib/fft.test.ts
Normal file
136
frontend/src/lib/fft.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fftInPlace, magnitudeSpectrum, nextPow2, powerSpectrumDb } from './fft'
|
||||
|
||||
function argmax(values: Float64Array): number {
|
||||
let best = 0
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
if (values[i] > values[best]) best = i
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Real-valued unit-amplitude sine/cosine exactly on FFT bin `bin`. */
|
||||
function tone(n: number, bin: number, fn: 'sin' | 'cos' = 'sin'): Float64Array {
|
||||
const out = new Float64Array(n)
|
||||
for (let t = 0; t < n; t++) {
|
||||
const angle = (2 * Math.PI * bin * t) / n
|
||||
out[t] = fn === 'sin' ? Math.sin(angle) : Math.cos(angle)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('nextPow2', () => {
|
||||
it('returns the smallest power of two >= n', () => {
|
||||
expect(nextPow2(1)).toBe(1)
|
||||
expect(nextPow2(2)).toBe(2)
|
||||
expect(nextPow2(3)).toBe(4)
|
||||
expect(nextPow2(1000)).toBe(1024)
|
||||
expect(nextPow2(1024)).toBe(1024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('magnitudeSpectrum', () => {
|
||||
it('peaks at the bin of an injected sine', () => {
|
||||
const n = 64
|
||||
const k = 5
|
||||
const mags = magnitudeSpectrum(tone(n, k), new Float64Array(n))
|
||||
expect(mags).toHaveLength(n / 2)
|
||||
expect(argmax(mags)).toBe(k)
|
||||
// A unit-amplitude sine concentrates N/2 of magnitude in its bin.
|
||||
expect(mags[k]).toBeCloseTo(n / 2, 6)
|
||||
expect(mags[k - 1]).toBeLessThan(1e-6)
|
||||
expect(mags[k + 1]).toBeLessThan(1e-6)
|
||||
})
|
||||
|
||||
it('peaks at the bin of a second, different frequency (cosine)', () => {
|
||||
const n = 128
|
||||
const k = 11
|
||||
const mags = magnitudeSpectrum(tone(n, k, 'cos'), new Float64Array(n))
|
||||
expect(argmax(mags)).toBe(k)
|
||||
expect(mags[k]).toBeCloseTo(n / 2, 6)
|
||||
})
|
||||
|
||||
it('places a DC signal entirely in bin 0', () => {
|
||||
const n = 64
|
||||
const mags = magnitudeSpectrum(new Float64Array(n).fill(1), new Float64Array(n))
|
||||
expect(mags[0]).toBeCloseTo(n, 6)
|
||||
for (let k = 1; k < n / 2; k++) {
|
||||
expect(mags[k]).toBeLessThan(1e-6)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mutate its inputs', () => {
|
||||
const re = tone(64, 3)
|
||||
const im = new Float64Array(64)
|
||||
const reBefore = Float64Array.from(re)
|
||||
const imBefore = Float64Array.from(im)
|
||||
magnitudeSpectrum(re, im)
|
||||
expect(re).toEqual(reBefore)
|
||||
expect(im).toEqual(imBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fftInPlace', () => {
|
||||
it('puts a Nyquist-rate alternating signal entirely in bin n/2', () => {
|
||||
const n = 64
|
||||
const re = new Float64Array(n)
|
||||
for (let t = 0; t < n; t++) re[t] = t % 2 === 0 ? 1 : -1
|
||||
const im = new Float64Array(n)
|
||||
fftInPlace(re, im) // in-place: the transform lands back in re/im
|
||||
// magnitudeSpectrum only returns the first n/2 bins, so the Nyquist bin
|
||||
// is verified here on the raw output instead.
|
||||
expect(Math.hypot(re[n / 2], im[n / 2])).toBeCloseTo(n, 6)
|
||||
expect(Math.hypot(re[0], im[0])).toBeLessThan(1e-9)
|
||||
})
|
||||
|
||||
it('transforms a unit impulse into a flat spectrum', () => {
|
||||
const n = 32
|
||||
const re = new Float64Array(n)
|
||||
re[0] = 1
|
||||
const im = new Float64Array(n)
|
||||
fftInPlace(re, im)
|
||||
for (let k = 0; k < n; k++) {
|
||||
expect(Math.hypot(re[k], im[k])).toBeCloseTo(1, 9)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mismatched re/im lengths', () => {
|
||||
expect(() => fftInPlace(new Float64Array(4), new Float64Array(8))).toThrow(/mismatch/)
|
||||
})
|
||||
|
||||
it('rejects non-power-of-two lengths', () => {
|
||||
expect(() => fftInPlace(new Float64Array(3), new Float64Array(3))).toThrow(/power of two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('powerSpectrumDb', () => {
|
||||
it('peaks near 0 dB at the bin of a unit complex exponential', () => {
|
||||
const n = 128
|
||||
const k = 7
|
||||
const iq = new Float64Array(2 * n)
|
||||
for (let t = 0; t < n; t++) {
|
||||
iq[2 * t] = Math.cos((2 * Math.PI * k * t) / n)
|
||||
iq[2 * t + 1] = Math.sin((2 * Math.PI * k * t) / n)
|
||||
}
|
||||
const db = powerSpectrumDb(iq)
|
||||
expect(db).toHaveLength(n / 2)
|
||||
expect(argmax(db)).toBe(k)
|
||||
// Unit amplitude => power (n^2)/(n^2) = 1 => ~0 dB at the peak.
|
||||
expect(db[k]).toBeCloseTo(0, 6)
|
||||
expect(db[k + 1]).toBeLessThan(-60)
|
||||
})
|
||||
|
||||
it('zero-pads non-power-of-two sample counts and still finds the peak', () => {
|
||||
const n = 128 // padded size chosen by nextPow2
|
||||
const samples = 100
|
||||
const k = 7
|
||||
const iq = new Float64Array(2 * samples)
|
||||
for (let t = 0; t < samples; t++) {
|
||||
iq[2 * t] = Math.cos((2 * Math.PI * k * t) / n)
|
||||
iq[2 * t + 1] = Math.sin((2 * Math.PI * k * t) / n)
|
||||
}
|
||||
const db = powerSpectrumDb(iq)
|
||||
expect(db).toHaveLength(n / 2)
|
||||
expect(argmax(db)).toBe(k)
|
||||
})
|
||||
})
|
||||
95
frontend/src/lib/fft.ts
Normal file
95
frontend/src/lib/fft.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Radix-2 Cooley–Tukey FFT, in-place, iterative, bit-reversal ordered.
|
||||
// Used by the RTL-SDR spectrum sweep. Length must be a power of two.
|
||||
|
||||
export type ComplexArray = { re: Float64Array; im: Float64Array }
|
||||
|
||||
export function nextPow2(n: number): number {
|
||||
let p = 1
|
||||
while (p < n) p <<= 1
|
||||
return p
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place iterative radix-2 FFT. `re`/`im` must have identical length
|
||||
* and that length must be a power of two.
|
||||
*/
|
||||
export function fftInPlace(re: Float64Array, im: Float64Array): void {
|
||||
const n = re.length
|
||||
if (n !== im.length) throw new Error('re/im length mismatch')
|
||||
if ((n & (n - 1)) !== 0) throw new Error('FFT length must be a power of two')
|
||||
if (n <= 1) return
|
||||
|
||||
// Bit-reversal permutation.
|
||||
for (let i = 1, j = 0; i < n; i++) {
|
||||
let bit = n >> 1
|
||||
for (; j & bit; bit >>= 1) j ^= bit
|
||||
j ^= bit
|
||||
if (i < j) {
|
||||
const tr = re[i]
|
||||
re[i] = re[j]
|
||||
re[j] = tr
|
||||
const ti = im[i]
|
||||
im[i] = im[j]
|
||||
im[j] = ti
|
||||
}
|
||||
}
|
||||
|
||||
for (let len = 2; len <= n; len <<= 1) {
|
||||
const half = len >> 1
|
||||
const ang = (-2 * Math.PI) / len
|
||||
const wr = Math.cos(ang)
|
||||
const wi = Math.sin(ang)
|
||||
for (let i = 0; i < n; i += len) {
|
||||
let cwr = 1
|
||||
let cwi = 0
|
||||
for (let j = 0; j < half; j++) {
|
||||
const uR = re[i + j]
|
||||
const uI = im[i + j]
|
||||
const vR = re[i + j + half] * cwr - im[i + j + half] * cwi
|
||||
const vI = re[i + j + half] * cwi + im[i + j + half] * cwr
|
||||
re[i + j] = uR + vR
|
||||
im[i + j] = uI + vI
|
||||
re[i + j + half] = uR - vR
|
||||
im[i + j + half] = uI - vI
|
||||
const nwr = cwr * wr - cwi * wi
|
||||
cwi = cwr * wi + cwi * wr
|
||||
cwr = nwr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience wrapper returning magnitude spectrum (first n/2 bins). */
|
||||
export function magnitudeSpectrum(re: Float64Array, im: Float64Array): Float64Array {
|
||||
const r = Float64Array.from(re)
|
||||
const i = Float64Array.from(im)
|
||||
fftInPlace(r, i)
|
||||
const half = r.length >> 1
|
||||
const mags = new Float64Array(half)
|
||||
for (let k = 0; k < half; k++) {
|
||||
mags[k] = Math.hypot(r[k], i[k])
|
||||
}
|
||||
return mags
|
||||
}
|
||||
|
||||
/**
|
||||
* Power spectrum in dBFS-ish units from interleaved I/Q samples.
|
||||
* `iq` layout: [i0, q0, i1, q1, ...]. Returns n/2 dB values.
|
||||
*/
|
||||
export function powerSpectrumDb(iq: Float64Array): Float64Array {
|
||||
const n = nextPow2(iq.length >> 1)
|
||||
const re = new Float64Array(n)
|
||||
const im = new Float64Array(n)
|
||||
for (let k = 0; k < n && 2 * k + 1 < iq.length; k++) {
|
||||
re[k] = iq[2 * k]
|
||||
im[k] = iq[2 * k + 1]
|
||||
}
|
||||
fftInPlace(re, im)
|
||||
const half = n >> 1
|
||||
const out = new Float64Array(half)
|
||||
for (let k = 0; k < half; k++) {
|
||||
const power = (re[k] * re[k] + im[k] * im[k]) / (n * n)
|
||||
out[k] = 10 * Math.log10(power + 1e-12)
|
||||
}
|
||||
return out
|
||||
}
|
||||
179
frontend/src/lib/planchette.test.ts
Normal file
179
frontend/src/lib/planchette.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PlanchetteMachine, normalizeWord, tokenize } from './planchette'
|
||||
|
||||
// Fast cadence so tests can drive the machine with small tick counts.
|
||||
const FAST = { msPerLetter: 100, msBetweenWords: 200 }
|
||||
|
||||
describe('normalizeWord', () => {
|
||||
it('uppercases and strips punctuation', () => {
|
||||
expect(normalizeWord('beware, the veil!')).toBe('BEWARE THE VEIL')
|
||||
})
|
||||
|
||||
it('keeps digits', () => {
|
||||
expect(normalizeWord('room 13, floor 4')).toBe('ROOM 13 FLOOR 4')
|
||||
})
|
||||
|
||||
it('drops accented letters outright (the board only knows A-Z)', () => {
|
||||
expect(normalizeWord('séance niño')).toBe('SANCE NIO')
|
||||
})
|
||||
|
||||
it('collapses whitespace and trims', () => {
|
||||
expect(normalizeWord(' hello \t spirit ')).toBe('HELLO SPIRIT')
|
||||
})
|
||||
|
||||
it('returns an empty string when nothing spellable remains', () => {
|
||||
expect(normalizeWord('!!! …')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tokenize', () => {
|
||||
it('splits an utterance into normalized words', () => {
|
||||
expect(tokenize('beware, the veil!')).toEqual(['BEWARE', 'THE', 'VEIL'])
|
||||
})
|
||||
|
||||
it('returns a single word as-is (normalized)', () => {
|
||||
expect(tokenize('hello')).toEqual(['HELLO'])
|
||||
})
|
||||
|
||||
it('returns an empty list when there is nothing to spell', () => {
|
||||
expect(tokenize('')).toEqual([])
|
||||
expect(tokenize('!!!')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanchetteMachine', () => {
|
||||
it('starts idle with an empty queue and history', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
const s = m.snapshot()
|
||||
expect(s.phase).toBe('idle')
|
||||
expect(s.current).toBeNull()
|
||||
expect(s.word).toBeNull()
|
||||
expect(s.index).toBe(0)
|
||||
expect(s.queued).toEqual([])
|
||||
expect(s.spelled).toEqual([])
|
||||
})
|
||||
|
||||
it('spells a word letter by letter on the per-letter cadence', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
m.enqueue('HI')
|
||||
|
||||
// Enqueueing alone does not start spelling.
|
||||
expect(m.snapshot().phase).toBe('idle')
|
||||
expect(m.snapshot().queued).toEqual(['HI'])
|
||||
|
||||
// The first tick pulls the word off the queue and hovers the first letter.
|
||||
let s = m.tick(0)
|
||||
expect(s.phase).toBe('moving')
|
||||
expect(s.word).toBe('HI')
|
||||
expect(s.current).toBe('H')
|
||||
expect(s.index).toBe(0)
|
||||
expect(s.queued).toEqual([])
|
||||
|
||||
// A partial interval keeps it dwelling on the same letter.
|
||||
s = m.tick(50)
|
||||
expect(s.phase).toBe('dwelling')
|
||||
expect(s.current).toBe('H')
|
||||
expect(s.index).toBe(0)
|
||||
|
||||
// Completing the interval advances to the next letter.
|
||||
s = m.tick(50)
|
||||
expect(s.current).toBe('I')
|
||||
expect(s.index).toBe(1)
|
||||
|
||||
// One more interval finishes the word: it lands in `spelled`.
|
||||
s = m.tick(100)
|
||||
expect(s.phase).toBe('returning')
|
||||
expect(s.current).toBeNull()
|
||||
expect(s.word).toBeNull()
|
||||
expect(s.spelled).toEqual(['HI'])
|
||||
|
||||
// Between-words pause, then back to idle.
|
||||
s = m.tick(100)
|
||||
expect(s.phase).toBe('returning')
|
||||
s = m.tick(100)
|
||||
expect(s.phase).toBe('idle')
|
||||
expect(s.current).toBeNull()
|
||||
})
|
||||
|
||||
it('spells queued words in FIFO order and idles after draining', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
m.enqueue('BEWARE THE VEIL')
|
||||
expect(m.snapshot().queued).toEqual(['BEWARE', 'THE', 'VEIL'])
|
||||
|
||||
for (let i = 0; i < 500; i++) m.tick(50)
|
||||
|
||||
const s = m.snapshot()
|
||||
expect(s.phase).toBe('idle')
|
||||
expect(s.current).toBeNull()
|
||||
expect(s.queued).toEqual([])
|
||||
expect(s.spelled).toEqual(['BEWARE', 'THE', 'VEIL'])
|
||||
})
|
||||
|
||||
it('reports queue -> spelled progress while draining', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
m.enqueue('AB CD')
|
||||
m.tick(0) // start AB
|
||||
m.tick(200) // finish AB -> returning
|
||||
|
||||
let s = m.snapshot()
|
||||
expect(s.spelled).toEqual(['AB'])
|
||||
expect(s.queued).toEqual(['CD'])
|
||||
|
||||
m.tick(200) // returning pause elapses -> idle
|
||||
m.tick(0) // start CD
|
||||
s = m.snapshot()
|
||||
expect(s.phase).toBe('moving')
|
||||
expect(s.word).toBe('CD')
|
||||
expect(s.current).toBe('C')
|
||||
expect(s.index).toBe(0)
|
||||
})
|
||||
|
||||
it('caps the pending queue at maxQueue', () => {
|
||||
const m = new PlanchetteMachine({ ...FAST, maxQueue: 2 })
|
||||
m.enqueue('ONE TWO THREE FOUR')
|
||||
expect(m.snapshot().queued).toEqual(['ONE', 'TWO'])
|
||||
})
|
||||
|
||||
it('clear() cancels pending and in-progress spelling but keeps history', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
m.enqueue('HI THERE')
|
||||
m.tick(0) // start HI
|
||||
m.tick(200) // finish HI -> returning, spelled: ['HI']
|
||||
|
||||
m.clear()
|
||||
const s = m.snapshot()
|
||||
expect(s.phase).toBe('idle')
|
||||
expect(s.current).toBeNull()
|
||||
expect(s.queued).toEqual([])
|
||||
// clear() empties pending work; the spelled history survives the session.
|
||||
expect(s.spelled).toEqual(['HI'])
|
||||
|
||||
// ...and the machine can be reused afterwards.
|
||||
m.enqueue('OK')
|
||||
m.tick(0)
|
||||
expect(m.snapshot().word).toBe('OK')
|
||||
})
|
||||
|
||||
it('ignores utterances with no spellable words', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
const version = m.getVersion()
|
||||
m.enqueue('!!! …')
|
||||
expect(m.snapshot().queued).toEqual([])
|
||||
expect(m.getVersion()).toBe(version) // no mutation, no version bump
|
||||
m.tick(0)
|
||||
expect(m.snapshot().phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('bumps the version counter on every mutation', () => {
|
||||
const m = new PlanchetteMachine(FAST)
|
||||
let v = m.getVersion()
|
||||
m.enqueue('HI')
|
||||
expect(m.getVersion()).toBeGreaterThan(v)
|
||||
v = m.getVersion()
|
||||
m.tick(0)
|
||||
expect(m.getVersion()).toBeGreaterThan(v)
|
||||
v = m.getVersion()
|
||||
m.clear()
|
||||
expect(m.getVersion()).toBeGreaterThan(v)
|
||||
})
|
||||
})
|
||||
158
frontend/src/lib/planchette.ts
Normal file
158
frontend/src/lib/planchette.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// Planchette word-queue / spelling state machine.
|
||||
//
|
||||
// The Ouija board spells out spirit utterances letter by letter. This module
|
||||
// is the pure logic: queue words, step through letters at a fixed cadence,
|
||||
// and report which character the planchette is currently hovering over.
|
||||
// Rendering (canvas, DOM) lives elsewhere; this is unit-tested in isolation.
|
||||
|
||||
export type PlanchettePhase = 'idle' | 'moving' | 'dwelling' | 'returning'
|
||||
|
||||
export type PlanchetteSnapshot = {
|
||||
phase: PlanchettePhase
|
||||
/** Character currently hovered, or null when drifting idle. */
|
||||
current: string | null
|
||||
/** The full normalized word being spelled, or null. */
|
||||
word: string | null
|
||||
/** Index of `current` within `word`. */
|
||||
index: number
|
||||
/** Words still waiting to be spelled. */
|
||||
queued: readonly string[]
|
||||
/** Words already spelled this session (most recent last). */
|
||||
spelled: readonly string[]
|
||||
}
|
||||
|
||||
export type PlanchetteOptions = {
|
||||
/** Milliseconds spent on each letter. Default 300. */
|
||||
msPerLetter?: number
|
||||
/** Milliseconds paused between words. Default 700. */
|
||||
msBetweenWords?: number
|
||||
/** Cap on the internal queue so a flood of utterances cannot wedge it. */
|
||||
maxQueue?: number
|
||||
}
|
||||
|
||||
/** Keep A–Z, 0–9 and spaces; uppercase; drop everything else. */
|
||||
export function normalizeWord(text: string): string {
|
||||
return text
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9 ]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an utterance into queueable word tokens: punctuation stripped,
|
||||
* whitespace-split, empties removed.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
const norm = normalizeWord(text)
|
||||
if (!norm) return []
|
||||
return norm.split(' ').filter((w) => w.length > 0)
|
||||
}
|
||||
|
||||
export class PlanchetteMachine {
|
||||
private queue: string[] = []
|
||||
private done: string[] = []
|
||||
private word: string | null = null
|
||||
private index = 0
|
||||
private phase: PlanchettePhase = 'idle'
|
||||
private clock = 0
|
||||
private readonly msPerLetter: number
|
||||
private readonly msBetweenWords: number
|
||||
private readonly maxQueue: number
|
||||
private version = 0
|
||||
|
||||
constructor(opts: PlanchetteOptions = {}) {
|
||||
this.msPerLetter = opts.msPerLetter ?? 300
|
||||
this.msBetweenWords = opts.msBetweenWords ?? 700
|
||||
this.maxQueue = opts.maxQueue ?? 64
|
||||
}
|
||||
|
||||
/** Monotonic counter bumped on every mutation — handy for render loops. */
|
||||
getVersion(): number {
|
||||
return this.version
|
||||
}
|
||||
|
||||
/** Enqueue an utterance (may contain several words + punctuation). */
|
||||
enqueue(text: string): void {
|
||||
const words = tokenize(text)
|
||||
for (const w of words) {
|
||||
if (this.queue.length < this.maxQueue) this.queue.push(w)
|
||||
}
|
||||
if (words.length > 0) this.version++
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.queue = []
|
||||
this.word = null
|
||||
this.index = 0
|
||||
this.phase = 'idle'
|
||||
this.clock = 0
|
||||
this.version++
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the machine by `dtMs`. Call from a rAF loop or a timer.
|
||||
* Returns a snapshot after advancing.
|
||||
*/
|
||||
tick(dtMs: number): PlanchetteSnapshot {
|
||||
this.clock += dtMs
|
||||
|
||||
if (this.phase === 'idle') {
|
||||
if (this.queue.length > 0) {
|
||||
this.word = this.queue.shift() ?? null
|
||||
this.index = 0
|
||||
this.phase = this.word && this.word.length > 0 ? 'moving' : 'idle'
|
||||
this.clock = 0
|
||||
this.version++
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
if (this.phase === 'returning') {
|
||||
if (this.clock >= this.msBetweenWords) {
|
||||
this.clock = 0
|
||||
this.phase = 'idle'
|
||||
this.word = null
|
||||
this.index = 0
|
||||
this.version++
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
// moving / dwelling: advance letters on the per-letter cadence.
|
||||
if (this.word) {
|
||||
while (this.clock >= this.msPerLetter && this.phase !== 'returning') {
|
||||
this.clock -= this.msPerLetter
|
||||
this.index++
|
||||
this.version++
|
||||
if (this.index >= this.word.length) {
|
||||
this.done.push(this.word)
|
||||
if (this.done.length > 128) this.done.shift()
|
||||
this.phase = 'returning'
|
||||
this.clock = 0
|
||||
} else {
|
||||
this.phase = this.clock >= this.msPerLetter ? 'moving' : 'dwelling'
|
||||
}
|
||||
}
|
||||
if (this.phase === 'dwelling' || this.phase === 'moving') {
|
||||
this.phase = 'dwelling'
|
||||
}
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
snapshot(): PlanchetteSnapshot {
|
||||
const active =
|
||||
(this.phase === 'moving' || this.phase === 'dwelling') && this.word
|
||||
? this.word
|
||||
: null
|
||||
return {
|
||||
phase: this.phase,
|
||||
current: active ? active[this.index] ?? null : null,
|
||||
word: active,
|
||||
index: active ? this.index : 0,
|
||||
queued: [...this.queue],
|
||||
spelled: [...this.done],
|
||||
}
|
||||
}
|
||||
}
|
||||
339
frontend/src/lib/sdr.ts
Normal file
339
frontend/src/lib/sdr.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Best-effort WebUSB driver for RTL2832U + R820T("T") based SDR dongles.
|
||||
//
|
||||
// ⚠️ HARDWARE PASS REQUIRED: this driver is structured from the public
|
||||
// librtlsdr register documentation and has NOT been validated against a real
|
||||
// device in this environment (no RTL-SDR attached). The control-transfer
|
||||
// sequences below follow the known-good init order (demod power-up, R820T
|
||||
// tuner init via I2C repeater, sample-rate set, FIR, bulk streaming) but
|
||||
// expect to debug register pokes with a logic analyzer / librtlsdr -T.
|
||||
// Every entry point fails soft: callers must treat any thrown error as
|
||||
// "this vessel cannot hear the radio dead" and degrade gracefully.
|
||||
|
||||
import { powerSpectrumDb } from './fft'
|
||||
|
||||
export type RtlSampleBlock = {
|
||||
/** Center frequency this block was captured at, Hz. */
|
||||
centerHz: number
|
||||
/** Sample rate used, Hz. */
|
||||
sampleRateHz: number
|
||||
/** Interleaved unsigned I/Q bytes, zero-centered: [i,q,i,q…] */
|
||||
iq: Uint8Array
|
||||
}
|
||||
|
||||
export type SweepCallbacks = {
|
||||
/** Per-tune power spectrum (dB values, fftSize/2 entries). */
|
||||
onSpectrum?: (centerHz: number, db: Float64Array) => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
// USB identification for RTL2832U dongles.
|
||||
export const RTL2832U_VENDOR = 0x0bda
|
||||
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]
|
||||
|
||||
// Request types used by librtlsdr.
|
||||
const CTRL_IN = 0xc0
|
||||
const CTRL_OUT = 0x40
|
||||
const DEMOD = 0x03
|
||||
const USB_EPA = 0x02
|
||||
const SYS = 0x09
|
||||
const PAGE_USB = 0x01
|
||||
|
||||
export function isSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
'usb' in navigator &&
|
||||
typeof navigator.usb?.requestDevice === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export class RtlSdr {
|
||||
private device: USBDevice | null = null
|
||||
private interfaceNumber = 0
|
||||
private endpointIn = 0x81
|
||||
private running = false
|
||||
private sampleRate = 2_048_000
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.device?.opened ?? false
|
||||
}
|
||||
|
||||
/** Ask the browser for an RTL2832U device. Throws if none chosen/found. */
|
||||
async requestDevice(): Promise<void> {
|
||||
if (!isSupported()) {
|
||||
throw new Error('WebUSB is not available in this vessel (Chromium required)')
|
||||
}
|
||||
const filters = [
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2832 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2834 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2838 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2837 },
|
||||
]
|
||||
this.device = await navigator.usb.requestDevice({ filters })
|
||||
}
|
||||
|
||||
/** Open, claim, and run the RTL2832U + R820T init sequence. */
|
||||
async open(sampleRateHz = 2_048_000): Promise<void> {
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device selected')
|
||||
this.sampleRate = sampleRateHz
|
||||
await dev.open()
|
||||
|
||||
// Find the first bulk-IN endpoint.
|
||||
const iface = dev.configuration?.interfaces[0]
|
||||
const alt = iface?.alternates[0]
|
||||
const ep = alt?.endpoints.find((e) => e.direction === 'in' && e.type === 'bulk')
|
||||
if (iface && alt && ep) {
|
||||
this.interfaceNumber = iface.interfaceNumber
|
||||
this.endpointIn = ep.endpointNumber
|
||||
}
|
||||
|
||||
// Detach kernel driver (Linux) — ignore failure: may not be supported.
|
||||
await this.claim()
|
||||
|
||||
// --- Init sequence (HARDWARE PASS REQUIRED) ---
|
||||
// Order follows librtlsdr: USB reset → demod init → tuner I2C init.
|
||||
await this.demodWrite(1, 0x01, 0x14, 1) // soft reset
|
||||
await this.demodWrite(1, 0x01, 0x10, 1)
|
||||
await this.demodWrite(0, 0x01, 0x08, 2) // demod_ctl
|
||||
await this.demodWrite(0, 0x06, 0x80, 1)
|
||||
await this.demodWrite(1, 0x15, 0x00, 1) // suspend off
|
||||
await this.demodWrite(1, 0x16, 0x00, 1)
|
||||
await this.demodWrite(1, 0x17, 0x00, 1)
|
||||
await this.demodWrite(1, 0x18, 0x00, 1)
|
||||
await this.demodWrite(1, 0x19, 0x00, 1)
|
||||
await this.demodWrite(1, 0x1a, 0x00, 1)
|
||||
await this.demodWrite(1, 0x1b, 0x00, 1)
|
||||
await this.demodWrite(1, 0x1c, 0x00, 1)
|
||||
await this.demodWrite(1, 0x0d, 0x83, 1) // standby off
|
||||
await this.demodWrite(1, 0x0b, 0x1b, 1) // AGC mode
|
||||
|
||||
// Power on tuner through I2C repeater (R820T at 0x1a).
|
||||
await this.i2cWrite(0x1a, 0x05, 0x8f) // LNA power on
|
||||
await this.i2cWrite(0x1a, 0x08, 0x80) // mixer
|
||||
await this.i2cWrite(0x1a, 0x0a, 0x10) // IF filter
|
||||
|
||||
await this.setSampleRate(this.sampleRate)
|
||||
await this.setFrequency(98_000_000)
|
||||
|
||||
// Reset endpoint before streaming.
|
||||
await this.writeReg(USB_EPA, 0x0001, 0xffff, 2)
|
||||
await this.demodWrite(1, 0x02, 0x00, 1)
|
||||
await this.demodWrite(0, 0x02, 0x40, 2)
|
||||
await this.demodWrite(1, 0x02, 0x00, 1) // enable test mode off
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.running = false
|
||||
const dev = this.device
|
||||
if (dev?.opened) {
|
||||
try {
|
||||
await this.demodWrite(1, 0x01, 0x10, 1) // suspend
|
||||
} catch {
|
||||
/* device may already be gone */
|
||||
}
|
||||
await dev.releaseInterface(this.interfaceNumber).catch(() => undefined)
|
||||
await dev.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tune the R820T mixer PLL. Hz. (HARDWARE PASS REQUIRED) */
|
||||
async setFrequency(hz: number): Promise<void> {
|
||||
// R820T fractional-N PLL programming via I2C. Simplified to the
|
||||
// integer part + common divider ratio; real librtlsdr computes the
|
||||
// exact sdm/vco from a 28.8MHz crystal reference. Marked for hardware.
|
||||
const loHz = hz + 3_570_000 // R820T IF offset
|
||||
const ref = 28_800_000
|
||||
const mixDiv = 2
|
||||
const nint = Math.floor(loHz / (ref * mixDiv))
|
||||
const vco = loHz % (ref * mixDiv)
|
||||
const sdm = Math.min(0xffff, Math.floor((vco * 65536) / (ref * mixDiv)))
|
||||
const reg = nint & 0x3f
|
||||
await this.i2cWrite(0x1a, 0x10, reg)
|
||||
await this.i2cWrite(0x1a, 0x11, (sdm >> 8) & 0xff)
|
||||
await this.i2cWrite(0x1a, 0x12, sdm & 0xff)
|
||||
}
|
||||
|
||||
/** Program demod resampling rate for the requested sample rate. */
|
||||
async setSampleRate(hz: number): Promise<void> {
|
||||
const crystal = 28_800_000
|
||||
const rsampRatio = Math.floor(((crystal * 2 ** 22) / hz) & 0x0ffffffc)
|
||||
await this.demodWrite(1, 0x9f, (rsampRatio >> 16) & 0xffff, 2)
|
||||
await this.demodWrite(1, 0xa1, rsampRatio & 0xffff, 2)
|
||||
this.sampleRate = hz
|
||||
}
|
||||
|
||||
/** Read one block of I/Q samples. Length must be multiple of 512. */
|
||||
async readSamples(bytes: number): Promise<Uint8Array> {
|
||||
const dev = this.device
|
||||
if (!dev?.opened) throw new Error('device not open')
|
||||
const res = await dev.transferIn(this.endpointIn, bytes)
|
||||
if (!res.data) throw new Error('bulk read failed')
|
||||
return new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous sweep across [startHz, endHz] in `stepHz` steps, emitting a
|
||||
* power spectrum per tuning step. Runs until `stopSweep()`.
|
||||
*/
|
||||
async sweep(
|
||||
startHz: number,
|
||||
endHz: number,
|
||||
stepHz: number,
|
||||
cb: SweepCallbacks,
|
||||
fftSize = 512,
|
||||
settleMs = 25,
|
||||
): Promise<void> {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
let hz = startHz
|
||||
const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq
|
||||
try {
|
||||
while (this.running) {
|
||||
await this.setFrequency(hz)
|
||||
await new Promise((r) => setTimeout(r, settleMs))
|
||||
try {
|
||||
await this.readSamples(16384) // discard: PLL settle
|
||||
const iq = await this.readSamples(readBytes)
|
||||
const f64 = new Float64Array(iq.length)
|
||||
for (let i = 0; i < iq.length; i++) f64[i] = (iq[i] - 127.5) / 128
|
||||
const db = powerSpectrumDb(f64)
|
||||
cb.onSpectrum?.(hz, db)
|
||||
} catch (err) {
|
||||
cb.onError?.(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
hz += stepHz
|
||||
if (hz > endHz) hz = startHz
|
||||
if (!this.running) break
|
||||
}
|
||||
} finally {
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
|
||||
stopSweep(): void {
|
||||
this.running = false
|
||||
}
|
||||
|
||||
// ---- Low-level USB helpers (private; HARDWARE PASS REQUIRED) ----
|
||||
|
||||
private async claim(): Promise<void> {
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
try {
|
||||
// Best-effort kernel driver detach; unsupported on some platforms.
|
||||
const anyDev = dev as unknown as {
|
||||
claimInterface(n: number): Promise<void>
|
||||
}
|
||||
await anyDev.claimInterface(this.interfaceNumber)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`could not claim the radio dead (interface busy?) — ${String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeReg(
|
||||
block: number,
|
||||
address: number,
|
||||
value: number,
|
||||
length: number,
|
||||
): Promise<void> {
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
|
||||
await dev.controlTransferOut({
|
||||
requestType: 'vendor',
|
||||
recipient: 'device',
|
||||
request: 0,
|
||||
value: (block << 8) | 0x10,
|
||||
index: address,
|
||||
}, data.subarray(0, length))
|
||||
}
|
||||
|
||||
private async demodWrite(
|
||||
page: number,
|
||||
address: number,
|
||||
value: number,
|
||||
length: number,
|
||||
): Promise<void> {
|
||||
// Demod registers are paged: index = (page << 8) | address.
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
|
||||
await dev.controlTransferOut({
|
||||
requestType: 'vendor',
|
||||
recipient: 'device',
|
||||
request: 0,
|
||||
value: (DEMOD << 8) | 0x10,
|
||||
index: (page << 8) | address,
|
||||
}, data.subarray(0, length))
|
||||
}
|
||||
|
||||
private async i2cWrite(i2cAddr: number, reg: number, value: number): Promise<void> {
|
||||
// I2C repeater: librtlsdr writes tuner registers by tunneling through
|
||||
// the demod's I2C master. Simplified single-byte write.
|
||||
const dev = this.device
|
||||
if (!dev) throw new Error('no device')
|
||||
await this.demodWrite(1, 0x02, 0x41, 1) // repeater on
|
||||
await dev.controlTransferOut({
|
||||
requestType: 'vendor',
|
||||
recipient: 'device',
|
||||
request: 0,
|
||||
value: (0x02 << 8) | 0x10,
|
||||
index: (i2cAddr << 8) | reg,
|
||||
}, new Uint8Array([value & 0xff]))
|
||||
await this.demodWrite(1, 0x02, 0x01, 1) // repeater off
|
||||
}
|
||||
|
||||
// Silence unused-warnings for constants kept for the hardware pass.
|
||||
private static readonly _refs = { CTRL_IN, CTRL_OUT, SYS, PAGE_USB }
|
||||
}
|
||||
|
||||
/** Rolling noise-floor + spike detector over sweep spectra (pure, testable). */
|
||||
export class SpectrumAnomalyDetector {
|
||||
private floor: Float64Array | null = null
|
||||
private lastEmit = -Infinity
|
||||
constructor(
|
||||
private readonly thresholdDb = 10,
|
||||
private readonly throttleMs = 2000,
|
||||
private readonly alpha = 0.1,
|
||||
) {}
|
||||
|
||||
/** Returns (peakHz, magnitudeDbOverFloor) when a spike fires, else null. */
|
||||
process(
|
||||
centerHz: number,
|
||||
sampleRateHz: number,
|
||||
db: Float64Array,
|
||||
nowMs: number,
|
||||
): { frequency: number; magnitude: number } | null {
|
||||
if (!this.floor || this.floor.length !== db.length) {
|
||||
this.floor = Float64Array.from(db)
|
||||
return null
|
||||
}
|
||||
let peak = 0
|
||||
let peakBin = -1
|
||||
for (let i = 0; i < db.length; i++) {
|
||||
const dev = db[i] - this.floor[i]
|
||||
if (dev > peak) {
|
||||
peak = dev
|
||||
peakBin = i
|
||||
}
|
||||
this.floor[i] += this.alpha * (db[i] - this.floor[i])
|
||||
}
|
||||
if (peakBin >= 0 && peak >= this.thresholdDb && nowMs - this.lastEmit >= this.throttleMs) {
|
||||
this.lastEmit = nowMs
|
||||
const binHz = sampleRateHz / 2 / db.length
|
||||
const offset = (peakBin - db.length / 2) * binHz
|
||||
return { frequency: (centerHz + offset) / 1e6, magnitude: peak }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.floor = null
|
||||
this.lastEmit = -Infinity
|
||||
}
|
||||
}
|
||||
|
||||
export const SWEEP_START_MHZ = 88
|
||||
export const SWEEP_END_MHZ = 108
|
||||
102
frontend/src/lib/types.ts
Normal file
102
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Shared protocol + domain types for the Quantumancy frontend.
|
||||
// Mirrors the backend contract exactly — do not invent changes.
|
||||
|
||||
export type Mode = 'wire' | 'evp' | 'radio' | 'ouija'
|
||||
export type Language = 'en' | 'es'
|
||||
export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
|
||||
export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade'
|
||||
|
||||
export type CodexVisual = {
|
||||
hue: number
|
||||
form: GhostForm
|
||||
}
|
||||
|
||||
export type CodexEntity = {
|
||||
id: string
|
||||
name: string
|
||||
epithet: string
|
||||
rarity: Rarity
|
||||
visual: CodexVisual
|
||||
quotes: string[]
|
||||
contact_count: number
|
||||
discovered_at: string
|
||||
discovered_by: string | null
|
||||
}
|
||||
|
||||
export type CodexEntityDetail = CodexEntity & {
|
||||
persona: string
|
||||
voice: Record<string, unknown>
|
||||
sightings: number
|
||||
}
|
||||
|
||||
export type SpiritVoice = {
|
||||
voice_id: string
|
||||
pitch: number
|
||||
rate: number
|
||||
noise: number
|
||||
echo: number
|
||||
}
|
||||
|
||||
export type SpiritEntity = {
|
||||
id: string
|
||||
name: string
|
||||
epithet: string
|
||||
persona: string
|
||||
rarity: Rarity
|
||||
voice: SpiritVoice
|
||||
visual: CodexVisual
|
||||
quotes: string[]
|
||||
contact_count: number
|
||||
discovered_at: string
|
||||
}
|
||||
|
||||
export type CodexListResponse = { entities: CodexEntity[] }
|
||||
|
||||
export type Stats = {
|
||||
entities: number
|
||||
sessions: number
|
||||
utterances: number
|
||||
anomalies: number
|
||||
}
|
||||
|
||||
export type SessionStatus = 'attuning' | 'summoning' | 'gathering'
|
||||
|
||||
// ---- WebSocket frames: client -> server ----
|
||||
|
||||
export type ClientFrame =
|
||||
| { type: 'ping' }
|
||||
| { type: 'set_mode'; mode: Mode }
|
||||
| { type: 'language'; language: Language }
|
||||
| { type: 'summon' }
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
|
||||
| { type: 'question'; text: string }
|
||||
| { type: 'passive'; enabled: boolean }
|
||||
|
||||
// ---- WebSocket frames: server -> client ----
|
||||
|
||||
export type Telemetry = {
|
||||
jitter_bytes_per_s: number
|
||||
latency_variance_ms: number
|
||||
latency_mean_ms: number
|
||||
dns_ms: number
|
||||
}
|
||||
|
||||
export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply'
|
||||
|
||||
export type ServerFrame =
|
||||
| { type: 'session'; id: string }
|
||||
| { type: 'pong' }
|
||||
| { type: 'mode'; mode: Mode }
|
||||
| { type: 'status'; state: SessionStatus }
|
||||
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
|
||||
| { type: 'anomaly_ack'; count: number }
|
||||
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
|
||||
| { type: 'audio'; id: string; url: string }
|
||||
| { type: 'reply_start' }
|
||||
| { type: 'reply_token'; token: string }
|
||||
| { type: 'reply_end'; id: string; text: string }
|
||||
| { type: 'telemetry' } & Telemetry
|
||||
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
|
||||
|
||||
// REST helpers
|
||||
export type SortOrder = 'recent' | 'contacted'
|
||||
295
frontend/src/lib/ws.test.ts
Normal file
295
frontend/src/lib/ws.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { VeilSocket } from './ws'
|
||||
import type { VeilConnectionState } from './ws'
|
||||
import type { ClientFrame, ServerFrame } from './types'
|
||||
|
||||
const URL = 'ws://quantumancy.test/ws/session'
|
||||
|
||||
/**
|
||||
* Minimal stand-in for the DOM WebSocket, driven manually by the tests.
|
||||
* readyState uses the real constant values so VeilSocket's
|
||||
* `readyState === WebSocket.OPEN` checks behave (WebSocket.OPEN === 1).
|
||||
*/
|
||||
class FakeWebSocket {
|
||||
readyState = 0 // CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
readonly sent: string[] = []
|
||||
|
||||
constructor(readonly url: string) {}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3 // CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
// ---- test drives ----
|
||||
|
||||
serverOpen(): void {
|
||||
this.readyState = 1 // WebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
serverSend(data: unknown): void {
|
||||
this.onmessage?.({ data })
|
||||
}
|
||||
|
||||
serverClose(): void {
|
||||
this.readyState = 3
|
||||
this.onclose?.()
|
||||
}
|
||||
}
|
||||
|
||||
type HarnessOptions = {
|
||||
baseBackoffMs?: number
|
||||
maxBackoffMs?: number
|
||||
pingIntervalMs?: number
|
||||
}
|
||||
|
||||
function makeHarness(opts: HarnessOptions = {}) {
|
||||
const sockets: FakeWebSocket[] = []
|
||||
const socket = new VeilSocket({
|
||||
url: URL,
|
||||
baseBackoffMs: opts.baseBackoffMs ?? 100,
|
||||
maxBackoffMs: opts.maxBackoffMs ?? 1000,
|
||||
pingIntervalMs: opts.pingIntervalMs ?? 1000,
|
||||
socketFactory: (url: string): WebSocket => {
|
||||
const fake = new FakeWebSocket(url)
|
||||
sockets.push(fake)
|
||||
return fake as unknown as WebSocket
|
||||
},
|
||||
})
|
||||
return { socket, sockets }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('VeilSocket', () => {
|
||||
it('queues frames while connecting and flushes them on open', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
const states: VeilConnectionState[] = []
|
||||
socket.onState((s) => states.push(s))
|
||||
|
||||
socket.connect()
|
||||
expect(socket.state).toBe('connecting')
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0].url).toBe(URL)
|
||||
|
||||
const summon: ClientFrame = { type: 'summon' }
|
||||
const question: ClientFrame = { type: 'question', text: 'is anyone there' }
|
||||
socket.send(summon)
|
||||
socket.send(question)
|
||||
expect(sockets[0].sent).toEqual([]) // nothing on the wire yet
|
||||
|
||||
sockets[0].serverOpen()
|
||||
expect(socket.state).toBe('open')
|
||||
expect(sockets[0].sent).toEqual([JSON.stringify(summon), JSON.stringify(question)])
|
||||
expect(states).toEqual(['connecting', 'open'])
|
||||
})
|
||||
|
||||
it('sends immediately once open', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
socket.send({ type: 'ping' })
|
||||
expect(sockets[0].sent).toEqual([JSON.stringify({ type: 'ping' })])
|
||||
})
|
||||
|
||||
it('dispatches parsed JSON frames to onFrame handlers', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
|
||||
const frames: ServerFrame[] = []
|
||||
const off = socket.onFrame((f) => frames.push(f))
|
||||
|
||||
const session: ServerFrame = { type: 'session', id: 'abc' }
|
||||
sockets[0].serverSend(JSON.stringify(session))
|
||||
expect(frames).toEqual([session])
|
||||
|
||||
off()
|
||||
sockets[0].serverSend(JSON.stringify({ type: 'pong' }))
|
||||
expect(frames).toHaveLength(1) // unsubscribed handler no longer called
|
||||
})
|
||||
|
||||
it('ignores malformed JSON and non-string payloads', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
|
||||
const frames: ServerFrame[] = []
|
||||
socket.onFrame((f) => frames.push(f))
|
||||
expect(() => {
|
||||
sockets[0].serverSend('}{ not json')
|
||||
sockets[0].serverSend(new Uint8Array([1, 2, 3]).buffer)
|
||||
sockets[0].serverSend(undefined)
|
||||
}).not.toThrow()
|
||||
expect(frames).toEqual([])
|
||||
})
|
||||
|
||||
it('drops frames sent while unstable instead of queueing them', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
sockets[0].serverClose()
|
||||
expect(socket.state).toBe('unstable')
|
||||
|
||||
socket.send({ type: 'summon' }) // dropped: not open, not connecting
|
||||
vi.advanceTimersByTime(100) // reconnect fires
|
||||
expect(sockets).toHaveLength(2)
|
||||
sockets[1].serverOpen()
|
||||
expect(sockets[1].sent).toEqual([]) // nothing stale flushed
|
||||
})
|
||||
|
||||
it('drops frames sent while closed', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
socket.close()
|
||||
socket.send({ type: 'summon' })
|
||||
expect(sockets[0].sent).toEqual([])
|
||||
})
|
||||
|
||||
it('goes unstable on unexpected close and reconnects with exponential backoff', () => {
|
||||
const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 1000 })
|
||||
const states: VeilConnectionState[] = []
|
||||
socket.onState((s) => states.push(s))
|
||||
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
sockets[0].serverClose()
|
||||
expect(socket.state).toBe('unstable')
|
||||
expect(sockets).toHaveLength(1)
|
||||
|
||||
// First retry after 100 ms (base * 2^0).
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(sockets).toHaveLength(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sockets).toHaveLength(2)
|
||||
expect(socket.state).toBe('connecting')
|
||||
|
||||
// Second drop: backoff doubles to 200 ms.
|
||||
sockets[1].serverClose()
|
||||
vi.advanceTimersByTime(199)
|
||||
expect(sockets).toHaveLength(2)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sockets).toHaveLength(3)
|
||||
|
||||
// The reconnect succeeds and the veil lifts again.
|
||||
sockets[2].serverOpen()
|
||||
expect(socket.state).toBe('open')
|
||||
expect(states).toEqual([
|
||||
'connecting',
|
||||
'open',
|
||||
'unstable',
|
||||
'connecting',
|
||||
'unstable',
|
||||
'connecting',
|
||||
'open',
|
||||
])
|
||||
})
|
||||
|
||||
it('caps the backoff at maxBackoffMs', () => {
|
||||
const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 250 })
|
||||
socket.connect()
|
||||
sockets[0].serverClose() // retry in 100 ms
|
||||
vi.advanceTimersByTime(100)
|
||||
sockets[1].serverClose() // retry in 200 ms
|
||||
vi.advanceTimersByTime(200)
|
||||
sockets[2].serverClose() // retry in min(250, 400) = 250 ms
|
||||
vi.advanceTimersByTime(249)
|
||||
expect(sockets).toHaveLength(3)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sockets).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('does not reconnect after a deliberate close()', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
socket.close()
|
||||
expect(socket.state).toBe('closed')
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(sockets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not reconnect when close() races a pending reconnect timer', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverClose() // reconnect scheduled in 100 ms
|
||||
socket.close() // deliberate: must cancel the pending timer
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(socket.state).toBe('closed')
|
||||
})
|
||||
|
||||
it('sends ping frames on the configured interval while open', () => {
|
||||
const { socket, sockets } = makeHarness({ pingIntervalMs: 1000 })
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(sockets[0].sent).toEqual([
|
||||
JSON.stringify({ type: 'ping' }),
|
||||
JSON.stringify({ type: 'ping' }),
|
||||
JSON.stringify({ type: 'ping' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('stops pinging after the connection drops', () => {
|
||||
const { socket, sockets } = makeHarness({ pingIntervalMs: 1000 })
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(sockets[0].sent).toHaveLength(2)
|
||||
|
||||
sockets[0].serverClose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(sockets[0].sent).toHaveLength(2) // frozen at the drop
|
||||
})
|
||||
|
||||
it('does not ping when pingIntervalMs is 0', () => {
|
||||
const { socket, sockets } = makeHarness({ pingIntervalMs: 0 })
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(sockets[0].sent).toEqual([])
|
||||
})
|
||||
|
||||
it('goes unstable and retries when the socket factory throws', () => {
|
||||
const sockets: FakeWebSocket[] = []
|
||||
let calls = 0
|
||||
const socket = new VeilSocket({
|
||||
url: URL,
|
||||
baseBackoffMs: 100,
|
||||
pingIntervalMs: 0,
|
||||
socketFactory: (): WebSocket => {
|
||||
calls++
|
||||
if (calls === 1) throw new Error('the veil refused the connection')
|
||||
const fake = new FakeWebSocket(URL)
|
||||
sockets.push(fake)
|
||||
return fake as unknown as WebSocket
|
||||
},
|
||||
})
|
||||
|
||||
socket.connect()
|
||||
expect(socket.state).toBe('unstable')
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(calls).toBe(2)
|
||||
expect(sockets).toHaveLength(1)
|
||||
sockets[0].serverOpen()
|
||||
expect(socket.state).toBe('open')
|
||||
})
|
||||
})
|
||||
171
frontend/src/lib/ws.ts
Normal file
171
frontend/src/lib/ws.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// Reconnecting WebSocket client for /ws/session with a typed event emitter.
|
||||
// Exponential backoff on unexpected close; surfaces connection status so the
|
||||
// UI can show "the connection to the other side is unstable".
|
||||
|
||||
import type { ClientFrame, ServerFrame } from './types'
|
||||
|
||||
export type VeilConnectionState =
|
||||
| 'connecting'
|
||||
| 'open'
|
||||
| 'unstable' // reconnecting after a drop
|
||||
| 'closed'
|
||||
|
||||
type FrameHandler = (frame: ServerFrame) => void
|
||||
type StateHandler = (state: VeilConnectionState) => void
|
||||
|
||||
export type VeilSocketOptions = {
|
||||
url?: string
|
||||
/** Base backoff delay (ms); doubles each retry up to maxBackoffMs. */
|
||||
baseBackoffMs?: number
|
||||
maxBackoffMs?: number
|
||||
/** Ping interval to keep the veil warm (ms). 0 disables. */
|
||||
pingIntervalMs?: number
|
||||
/** Injectable WebSocket constructor (tests). */
|
||||
socketFactory?: (url: string) => WebSocket
|
||||
}
|
||||
|
||||
export function defaultSessionUrl(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
|
||||
return `${proto}${location.host}/ws/session`
|
||||
}
|
||||
|
||||
export class VeilSocket {
|
||||
private ws: WebSocket | null = null
|
||||
private frameHandlers = new Set<FrameHandler>()
|
||||
private stateHandlers = new Set<StateHandler>()
|
||||
private readonly url: string
|
||||
private readonly baseBackoffMs: number
|
||||
private readonly maxBackoffMs: number
|
||||
private readonly pingIntervalMs: number
|
||||
private readonly socketFactory: (url: string) => WebSocket
|
||||
private attempts = 0
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private deliberatelyClosed = false
|
||||
private outbox: string[] = []
|
||||
private _state: VeilConnectionState = 'closed'
|
||||
|
||||
constructor(opts: VeilSocketOptions = {}) {
|
||||
this.url = opts.url ?? defaultSessionUrl()
|
||||
this.baseBackoffMs = opts.baseBackoffMs ?? 800
|
||||
this.maxBackoffMs = opts.maxBackoffMs ?? 15000
|
||||
this.pingIntervalMs = opts.pingIntervalMs ?? 25000
|
||||
this.socketFactory = opts.socketFactory ?? ((url: string) => new WebSocket(url))
|
||||
}
|
||||
|
||||
get state(): VeilConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
onFrame(handler: FrameHandler): () => void {
|
||||
this.frameHandlers.add(handler)
|
||||
return () => this.frameHandlers.delete(handler)
|
||||
}
|
||||
|
||||
onState(handler: StateHandler): () => void {
|
||||
this.stateHandlers.add(handler)
|
||||
return () => this.stateHandlers.delete(handler)
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.deliberatelyClosed = false
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.deliberatelyClosed = true
|
||||
this.clearTimers()
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this.setState('closed')
|
||||
}
|
||||
|
||||
/** Send a frame; queued while connecting, dropped while closed/unstable. */
|
||||
send(frame: ClientFrame): void {
|
||||
const raw = JSON.stringify(frame)
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(raw)
|
||||
} else if (this._state === 'connecting') {
|
||||
if (this.outbox.length < 64) this.outbox.push(raw)
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: VeilConnectionState): void {
|
||||
if (this._state === state) return
|
||||
this._state = state
|
||||
this.stateHandlers.forEach((h) => h(state))
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.pingTimer !== null) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
this.clearTimers()
|
||||
this.setState('connecting')
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = this.socketFactory(this.url)
|
||||
} catch {
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
this.attempts = 0
|
||||
this.setState('open')
|
||||
// Flush frames queued while connecting.
|
||||
for (const raw of this.outbox) ws.send(raw)
|
||||
this.outbox = []
|
||||
if (this.pingIntervalMs > 0) {
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
}, this.pingIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (typeof ev.data !== 'string') return
|
||||
let frame: ServerFrame
|
||||
try {
|
||||
frame = JSON.parse(ev.data) as ServerFrame
|
||||
} catch {
|
||||
return // ignore malformed whispers
|
||||
}
|
||||
this.frameHandlers.forEach((h) => h(frame))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
this.clearTimers()
|
||||
this.ws = null
|
||||
if (!this.deliberatelyClosed) this.scheduleReconnect()
|
||||
else this.setState('closed')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose follows onerror; nothing extra to do here.
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState('unstable')
|
||||
const delay = Math.min(
|
||||
this.maxBackoffMs,
|
||||
this.baseBackoffMs * 2 ** this.attempts,
|
||||
)
|
||||
this.attempts++
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (!this.deliberatelyClosed) this.openSocket()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@fontsource/cinzel'
|
||||
import '@fontsource/ibm-plex-mono'
|
||||
import './i18n'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
|
||||
482
frontend/src/pages/CodexEntityPage.css
Normal file
482
frontend/src/pages/CodexEntityPage.css
Normal file
@@ -0,0 +1,482 @@
|
||||
/* Codex dossier — single-spirit occult record. Same token palette as the registry:
|
||||
bg #07070d, panel rgba(16,16,26,0.85), phosphor #7cffb2, violet #b26bff,
|
||||
blood #ff3b5c (sparingly), borders rgba(124,255,178,0.18). */
|
||||
|
||||
.codex-detail-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* scanlines + vignette */
|
||||
.codex-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
/* ---- nav / states ---- */
|
||||
|
||||
.codex-detail-nav {
|
||||
max-width: 780px;
|
||||
margin: 0 auto 2.2rem;
|
||||
}
|
||||
|
||||
.codex-detail-back {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.codex-detail-back:hover,
|
||||
.codex-detail-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.codex-detail-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 5rem 0;
|
||||
animation: codex-detail-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.codex-detail-state-error {
|
||||
color: #ff3b5c;
|
||||
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- 404 / not reached ---- */
|
||||
|
||||
.codex-detail-missing {
|
||||
max-width: 560px;
|
||||
margin: 4rem auto 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
|
||||
.codex-detail-missing-sigil {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(124, 255, 178, 0.35);
|
||||
box-shadow:
|
||||
inset 0 0 30px rgba(124, 255, 178, 0.06),
|
||||
0 0 24px rgba(124, 255, 178, 0.08);
|
||||
animation: codex-detail-sigil-spin 26s linear infinite;
|
||||
}
|
||||
|
||||
.codex-detail-missing-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.4rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
margin: 0;
|
||||
color: rgba(238, 247, 240, 0.85);
|
||||
text-shadow: 0 0 18px rgba(124, 255, 178, 0.25);
|
||||
animation: codex-detail-flicker 9s linear infinite;
|
||||
}
|
||||
|
||||
/* ---- dossier sheet ---- */
|
||||
|
||||
.codex-dossier {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: clamp(1.8rem, 5vw, 3.2rem);
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 40px rgba(124, 255, 178, 0.05);
|
||||
}
|
||||
|
||||
.codex-dossier::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(124, 255, 178, 0.08);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.codex-dossier[data-rarity='rare'] {
|
||||
border-color: rgba(178, 107, 255, 0.35);
|
||||
}
|
||||
|
||||
.codex-dossier[data-rarity='mythic'] {
|
||||
border-color: rgba(255, 157, 74, 0.4);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 44px rgba(255, 157, 74, 0.1);
|
||||
}
|
||||
|
||||
/* ---- dossier header ---- */
|
||||
|
||||
.codex-dossier-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.55rem;
|
||||
padding-bottom: 1.8rem;
|
||||
border-bottom: 1px solid rgba(124, 255, 178, 0.14);
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
|
||||
.codex-dossier-glyph {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
hsl(var(--glyph-hue), 82%, 74%) 0%,
|
||||
hsl(var(--glyph-hue), 80%, 65%) 24%,
|
||||
hsla(var(--glyph-hue), 80%, 55%, 0.3) 52%,
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(2px);
|
||||
opacity: 0.92;
|
||||
animation: codex-detail-glyph-drift 8s ease-in-out infinite;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.codex-dossier-form {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
.codex-dossier-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.9rem, 5vw, 2.8rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
margin: 0.2rem 0 0;
|
||||
color: #eef7f0;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.3),
|
||||
0 0 46px rgba(178, 107, 255, 0.2);
|
||||
animation: codex-detail-flicker 11s linear infinite;
|
||||
}
|
||||
|
||||
.codex-dossier-epithet {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-style: italic;
|
||||
color: rgba(178, 107, 255, 0.8);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.26rem 0.85rem;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='common'] {
|
||||
color: #8a8f98;
|
||||
border-color: rgba(138, 143, 152, 0.45);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='uncommon'] {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='rare'] {
|
||||
color: #b26bff;
|
||||
border-color: rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.codex-dossier-rarity[data-rarity='mythic'] {
|
||||
color: #ff9d4a;
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
|
||||
box-shadow: 0 0 16px rgba(255, 157, 74, 0.32), inset 0 0 8px rgba(255, 157, 74, 0.12);
|
||||
}
|
||||
|
||||
/* ---- sections ---- */
|
||||
|
||||
.codex-dossier-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.codex-dossier-heading {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.6);
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
.codex-dossier-heading::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 1px;
|
||||
margin-top: 0.45rem;
|
||||
background: linear-gradient(90deg, rgba(124, 255, 178, 0.5), transparent);
|
||||
}
|
||||
|
||||
.codex-dossier-persona {
|
||||
margin: 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.75;
|
||||
color: rgba(238, 247, 240, 0.85);
|
||||
}
|
||||
|
||||
.codex-dossier-quotes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.codex-dossier-quote {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(216, 230, 220, 0.8);
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(178, 107, 255, 0.35);
|
||||
}
|
||||
|
||||
.codex-dossier-none {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
/* ---- stats row ---- */
|
||||
|
||||
.codex-dossier-stats {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1px;
|
||||
background: rgba(124, 255, 178, 0.12);
|
||||
border: 1px solid rgba(124, 255, 178, 0.12);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codex-dossier-stat {
|
||||
background: rgba(10, 10, 18, 0.85);
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.codex-dossier-stat dt {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.42);
|
||||
}
|
||||
|
||||
.codex-dossier-stat dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 10px rgba(124, 255, 178, 0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- voice signature table ---- */
|
||||
|
||||
.codex-dossier-voice {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid rgba(124, 255, 178, 0.14);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.codex-dossier-voice th,
|
||||
.codex-dossier-voice td {
|
||||
padding: 0.6rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(124, 255, 178, 0.08);
|
||||
}
|
||||
|
||||
.codex-dossier-voice tr:last-child th,
|
||||
.codex-dossier-voice tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.codex-dossier-voice tr:nth-child(even) th,
|
||||
.codex-dossier-voice tr:nth-child(even) td {
|
||||
background: rgba(124, 255, 178, 0.03);
|
||||
}
|
||||
|
||||
.codex-dossier-voice th {
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.64rem;
|
||||
color: rgba(216, 230, 220, 0.5);
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.codex-dossier-voice td {
|
||||
color: #b26bff;
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- CTA ---- */
|
||||
|
||||
.codex-dossier-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.codex-detail-cta {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.26em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border: 1px solid #7cffb2;
|
||||
border-radius: 6px;
|
||||
padding: 0.85rem 2.2rem;
|
||||
box-shadow: 0 0 22px rgba(124, 255, 178, 0.35);
|
||||
transition: box-shadow 180ms ease, transform 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.codex-detail-cta:hover,
|
||||
.codex-detail-cta:focus-visible {
|
||||
background: #a5ffcb;
|
||||
box-shadow: 0 0 34px rgba(124, 255, 178, 0.6);
|
||||
transform: translateY(-2px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.codex-detail-cta-ghost {
|
||||
color: #7cffb2;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.codex-detail-cta-ghost:hover,
|
||||
.codex-detail-cta-ghost:focus-visible {
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
}
|
||||
|
||||
/* ---- motion ---- */
|
||||
|
||||
@keyframes codex-detail-flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
91% {
|
||||
opacity: 1;
|
||||
}
|
||||
92% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
93% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
96% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-detail-glyph-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px) scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-detail-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-detail-sigil-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.codex-dossier-name,
|
||||
.codex-dossier-glyph,
|
||||
.codex-detail-state,
|
||||
.codex-detail-missing-sigil,
|
||||
.codex-detail-missing-title {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.codex-detail-cta,
|
||||
.codex-detail-back {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
209
frontend/src/pages/CodexEntityPage.tsx
Normal file
209
frontend/src/pages/CodexEntityPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// Codex dossier — a single spirit's occult record at /codex/:id.
|
||||
// Fetches GET /api/codex/:id; 404s render a themed "not reached" state.
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CodexEntityDetail } from '../lib/types'
|
||||
import './CodexEntityPage.css'
|
||||
|
||||
type DetailState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'notfound' }
|
||||
| { status: 'ready'; entity: CodexEntityDetail }
|
||||
|
||||
function formatDate(iso: string, locale: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/** Voice profiles are loose records — render whatever the veil preserved. */
|
||||
function formatVoiceValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function CodexEntityPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [state, setState] = useState<DetailState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setState({ status: 'notfound' })
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
setState({ status: 'loading' })
|
||||
|
||||
fetch(`/api/codex/${encodeURIComponent(id)}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 404) {
|
||||
setState({ status: 'notfound' })
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
|
||||
const entity = (await res.json()) as CodexEntityDetail
|
||||
setState({ status: 'ready', entity })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState({ status: 'error' })
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<div className="codex-detail-page">
|
||||
<div className="codex-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<nav className="codex-detail-nav">
|
||||
<Link to="/codex" className="codex-detail-back">
|
||||
{t('codex.back')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<p className="codex-detail-state">
|
||||
{t('codex.detail.loading', 'summoning the dossier…')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<p className="codex-detail-state codex-detail-state-error" role="alert">
|
||||
{t('common.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'notfound' && (
|
||||
<div className="codex-detail-missing">
|
||||
<div className="codex-detail-missing-sigil" aria-hidden="true" />
|
||||
<h1 className="codex-detail-missing-title">
|
||||
{t('codex.detail.notReached', 'this spirit has not been reached')}
|
||||
</h1>
|
||||
<Link to="/codex" className="codex-detail-cta codex-detail-cta-ghost">
|
||||
{t('codex.back')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' && (
|
||||
<Dossier entity={state.entity} locale={i18n.language} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string }) {
|
||||
const { t } = useTranslation()
|
||||
const discoverer = entity.discovered_by ?? t('codex.detail.unknownMedium', 'unknown medium')
|
||||
|
||||
const stats: Array<{ label: string; value: string }> = [
|
||||
{
|
||||
label: t('codex.detail.stats.sightings', 'sightings'),
|
||||
value: String(entity.sightings),
|
||||
},
|
||||
{
|
||||
label: t('codex.detail.stats.contacts', 'contacts'),
|
||||
value: String(entity.contact_count),
|
||||
},
|
||||
{
|
||||
label: t('codex.detail.stats.discoveredBy', 'discovered by'),
|
||||
value: discoverer,
|
||||
},
|
||||
{
|
||||
label: t('codex.detail.stats.discoveredAt', 'entered the registry'),
|
||||
value: formatDate(entity.discovered_at, locale),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<article className="codex-dossier" data-rarity={entity.rarity}>
|
||||
<header className="codex-dossier-header">
|
||||
<div
|
||||
className="codex-dossier-glyph"
|
||||
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="codex-dossier-form">
|
||||
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
|
||||
</span>
|
||||
<h1 className="codex-dossier-name">{entity.name}</h1>
|
||||
<p className="codex-dossier-epithet">{entity.epithet}</p>
|
||||
<span className="codex-dossier-rarity" data-rarity={entity.rarity}>
|
||||
{t(`codex.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="codex-dossier-section">
|
||||
<h2 className="codex-dossier-heading">{t('codex.persona')}</h2>
|
||||
<p className="codex-dossier-persona">{entity.persona}</p>
|
||||
</section>
|
||||
|
||||
<section className="codex-dossier-section">
|
||||
<h2 className="codex-dossier-heading">
|
||||
{t('codex.detail.utterances', 'known utterances')}
|
||||
</h2>
|
||||
{entity.quotes.length > 0 ? (
|
||||
<ul className="codex-dossier-quotes">
|
||||
{entity.quotes.map((quote, i) => (
|
||||
<li key={i} className="codex-dossier-quote">
|
||||
“{quote}”
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="codex-dossier-none">
|
||||
{t('codex.detail.noUtterances', 'no utterances recorded yet')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="codex-dossier-section">
|
||||
<dl className="codex-dossier-stats">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="codex-dossier-stat">
|
||||
<dt>{stat.label}</dt>
|
||||
<dd>{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="codex-dossier-section">
|
||||
<h2 className="codex-dossier-heading">
|
||||
{t('codex.detail.voiceSignature', 'voice signature')}
|
||||
</h2>
|
||||
<table className="codex-dossier-voice">
|
||||
<tbody>
|
||||
{Object.entries(entity.voice).map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<th scope="row">{key.replace(/_/g, ' ')}</th>
|
||||
<td>{formatVoiceValue(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<footer className="codex-dossier-footer">
|
||||
<Link to="/seance" className="codex-detail-cta">
|
||||
{t('codex.detail.attemptContact', 'attempt contact')}
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
412
frontend/src/pages/CodexPage.css
Normal file
412
frontend/src/pages/CodexPage.css
Normal file
@@ -0,0 +1,412 @@
|
||||
/* The Codex — gothic registry grid. Tokens: bg #07070d, panel rgba(16,16,26,0.85),
|
||||
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18). */
|
||||
|
||||
.codex-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* scanlines + vignette */
|
||||
.codex-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
/* ---- header ---- */
|
||||
|
||||
.codex-header {
|
||||
text-align: center;
|
||||
margin: 0 auto 2.5rem;
|
||||
max-width: 760px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codex-back {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.6rem;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.codex-back:hover,
|
||||
.codex-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.codex-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(2.2rem, 6vw, 3.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
margin: 0 0 0.6rem;
|
||||
color: #eef7f0;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.35),
|
||||
0 0 46px rgba(178, 107, 255, 0.25);
|
||||
animation: codex-flicker 9s linear infinite;
|
||||
}
|
||||
|
||||
.codex-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- control pills ---- */
|
||||
|
||||
.codex-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8rem 2rem;
|
||||
margin-bottom: 2.6rem;
|
||||
}
|
||||
|
||||
.codex-pill-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.codex-pill {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.42rem 0.95rem;
|
||||
color: rgba(216, 230, 220, 0.6);
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, border-color 160ms ease, box-shadow 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.codex-pill:hover {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-pill:focus-visible {
|
||||
outline: none;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.25), 0 0 14px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.codex-pill-active,
|
||||
.codex-pill-active:hover {
|
||||
color: #07070d;
|
||||
background: #7cffb2;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-pill-active[data-rarity='rare'] {
|
||||
background: #b26bff;
|
||||
border-color: #b26bff;
|
||||
box-shadow: 0 0 16px rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.codex-pill-active[data-rarity='mythic'] {
|
||||
background: #ff9d4a;
|
||||
border-color: #ff9d4a;
|
||||
box-shadow: 0 0 18px rgba(255, 157, 74, 0.55);
|
||||
}
|
||||
|
||||
.codex-pill-active[data-rarity='common'] {
|
||||
background: #8a8f98;
|
||||
border-color: #8a8f98;
|
||||
box-shadow: 0 0 14px rgba(138, 143, 152, 0.4);
|
||||
}
|
||||
|
||||
/* ---- states ---- */
|
||||
|
||||
.codex-state {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
padding: 4rem 0;
|
||||
animation: codex-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.codex-state-error {
|
||||
color: #ff3b5c;
|
||||
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- grid + cards ---- */
|
||||
|
||||
.codex-grid {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
max-width: 1280px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.codex-card-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.codex-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 1.8rem 1.4rem 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.codex-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(124, 255, 178, 0.05), transparent 35%);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.codex-card:hover,
|
||||
.codex-card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(124, 255, 178, 0.5);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(124, 255, 178, 0.12),
|
||||
0 10px 34px rgba(0, 0, 0, 0.55),
|
||||
0 0 26px rgba(124, 255, 178, 0.14);
|
||||
}
|
||||
|
||||
.codex-card:hover::before,
|
||||
.codex-card:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.codex-card:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.codex-card[data-rarity='rare']:hover,
|
||||
.codex-card[data-rarity='rare']:focus-visible {
|
||||
border-color: rgba(178, 107, 255, 0.55);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 26px rgba(178, 107, 255, 0.2);
|
||||
}
|
||||
|
||||
.codex-card[data-rarity='mythic']:hover,
|
||||
.codex-card[data-rarity='mythic']:focus-visible {
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 30px rgba(255, 157, 74, 0.22);
|
||||
}
|
||||
|
||||
/* ---- ghost glyph ---- */
|
||||
|
||||
.codex-glyph-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.codex-glyph {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
hsl(var(--glyph-hue), 82%, 74%) 0%,
|
||||
hsl(var(--glyph-hue), 80%, 65%) 24%,
|
||||
hsla(var(--glyph-hue), 80%, 55%, 0.32) 50%,
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(1.5px);
|
||||
opacity: 0.9;
|
||||
animation: codex-glyph-drift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.codex-glyph-form {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 220, 0.4);
|
||||
}
|
||||
|
||||
/* ---- card body ---- */
|
||||
|
||||
.codex-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.codex-card-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.28rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.codex-card-epithet {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(178, 107, 255, 0.75);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.codex-rarity {
|
||||
display: inline-block;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.22rem 0.7rem;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='common'] {
|
||||
color: #8a8f98;
|
||||
border-color: rgba(138, 143, 152, 0.45);
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='uncommon'] {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='rare'] {
|
||||
color: #b26bff;
|
||||
border-color: rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
.codex-rarity[data-rarity='mythic'] {
|
||||
color: #ff9d4a;
|
||||
border-color: rgba(255, 157, 74, 0.55);
|
||||
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
|
||||
box-shadow: 0 0 14px rgba(255, 157, 74, 0.3), inset 0 0 8px rgba(255, 157, 74, 0.12);
|
||||
}
|
||||
|
||||
.codex-card-quote {
|
||||
margin: 0.5rem 0 0;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(216, 230, 220, 0.78);
|
||||
}
|
||||
|
||||
.codex-card-meta {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
}
|
||||
|
||||
.codex-card-discoverer {
|
||||
color: rgba(216, 230, 220, 0.38);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ---- motion ---- */
|
||||
|
||||
@keyframes codex-flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
91% {
|
||||
opacity: 1;
|
||||
}
|
||||
92% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
93% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
96% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-glyph-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.06);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes codex-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.codex-title,
|
||||
.codex-glyph,
|
||||
.codex-state {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.codex-card,
|
||||
.codex-pill,
|
||||
.codex-back {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
190
frontend/src/pages/CodexPage.tsx
Normal file
190
frontend/src/pages/CodexPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// The Codex — public registry of every spirit ever contacted.
|
||||
// Filterable / sortable grid of entity dossiers, fetched from GET /api/codex.
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CodexEntity, CodexListResponse, Rarity, SortOrder } from '../lib/types'
|
||||
import './CodexPage.css'
|
||||
|
||||
type RarityFilter = Rarity | 'all'
|
||||
type LoadState = 'loading' | 'error' | 'ready'
|
||||
|
||||
const RARITIES: readonly Rarity[] = ['common', 'uncommon', 'rare', 'mythic']
|
||||
const SORTS: readonly SortOrder[] = ['recent', 'contacted']
|
||||
|
||||
function formatDate(iso: string, locale: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function CodexPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [rarity, setRarity] = useState<RarityFilter>('all')
|
||||
const [sort, setSort] = useState<SortOrder>('recent')
|
||||
const [entities, setEntities] = useState<CodexEntity[]>([])
|
||||
const [state, setState] = useState<LoadState>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setState('loading')
|
||||
const params = new URLSearchParams({ sort, limit: '120' })
|
||||
if (rarity !== 'all') params.set('rarity', rarity)
|
||||
|
||||
fetch(`/api/codex?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
|
||||
return (await res.json()) as CodexListResponse
|
||||
})
|
||||
.then((data) => {
|
||||
setEntities(data.entities)
|
||||
setState('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setState('error')
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [rarity, sort])
|
||||
|
||||
return (
|
||||
<div className="codex-page">
|
||||
<div className="codex-overlay" aria-hidden="true" />
|
||||
|
||||
<header className="codex-header">
|
||||
<Link to="/" className="codex-back">
|
||||
{t('codex.header.back', '← the threshold')}
|
||||
</Link>
|
||||
<h1 className="codex-title">{t('codex.header.title', 'THE CODEX')}</h1>
|
||||
<p className="codex-subtitle">
|
||||
{t(
|
||||
'codex.header.subtitle',
|
||||
'every spirit ever contacted, catalogued by the seekers who reached them',
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="codex-controls">
|
||||
<div
|
||||
className="codex-pill-group"
|
||||
role="group"
|
||||
aria-label={t('codex.controls.rarity', 'filter by rarity')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`codex-pill${rarity === 'all' ? ' codex-pill-active' : ''}`}
|
||||
aria-pressed={rarity === 'all'}
|
||||
onClick={() => setRarity('all')}
|
||||
>
|
||||
{t('codex.filterAll')}
|
||||
</button>
|
||||
{RARITIES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={`codex-pill${rarity === r ? ' codex-pill-active' : ''}`}
|
||||
data-rarity={r}
|
||||
aria-pressed={rarity === r}
|
||||
onClick={() => setRarity(r)}
|
||||
>
|
||||
{t(`codex.rarity.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="codex-pill-group"
|
||||
role="group"
|
||||
aria-label={t('codex.controls.sort', 'sort the registry')}
|
||||
>
|
||||
{SORTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`codex-pill${sort === s ? ' codex-pill-active' : ''}`}
|
||||
aria-pressed={sort === s}
|
||||
onClick={() => setSort(s)}
|
||||
>
|
||||
{t(`codex.sort.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && (
|
||||
<p className="codex-state">{t('codex.state.loading', 'consulting the registry…')}</p>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<p className="codex-state codex-state-error" role="alert">
|
||||
{t('common.error')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'ready' && entities.length === 0 && (
|
||||
<p className="codex-state">
|
||||
{t('codex.state.empty', 'no spirits of this tier have been reached yet')}
|
||||
</p>
|
||||
)}
|
||||
{state === 'ready' && entities.length > 0 && (
|
||||
<ul className="codex-grid">
|
||||
{entities.map((entity) => (
|
||||
<CodexCard key={entity.id} entity={entity} locale={i18n.language} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodexCard({ entity, locale }: { entity: CodexEntity; locale: string }) {
|
||||
const { t } = useTranslation()
|
||||
const discoverer = entity.discovered_by ?? t('codex.card.unknownMedium', 'unknown medium')
|
||||
|
||||
return (
|
||||
<li className="codex-card-item">
|
||||
<Link to={`/codex/${entity.id}`} className="codex-card" data-rarity={entity.rarity}>
|
||||
<div className="codex-glyph-wrap">
|
||||
<div
|
||||
className="codex-glyph"
|
||||
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="codex-glyph-form">
|
||||
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="codex-card-body">
|
||||
<h2 className="codex-card-name">{entity.name}</h2>
|
||||
<p className="codex-card-epithet">{entity.epithet}</p>
|
||||
<span className="codex-rarity" data-rarity={entity.rarity}>
|
||||
{t(`codex.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
{entity.quotes.length > 0 && (
|
||||
<blockquote className="codex-card-quote">“{entity.quotes[0]}”</blockquote>
|
||||
)}
|
||||
<p className="codex-card-meta">
|
||||
{t('codex.card.contacted', {
|
||||
count: entity.contact_count,
|
||||
defaultValue: 'contacted {{count}} times',
|
||||
defaultValue_one: 'contacted {{count}} time',
|
||||
defaultValue_other: 'contacted {{count}} times',
|
||||
})}
|
||||
</p>
|
||||
<p className="codex-card-meta codex-card-discoverer">
|
||||
{t('codex.card.discoveredBy', {
|
||||
name: discoverer,
|
||||
date: formatDate(entity.discovered_at, locale),
|
||||
defaultValue: 'discovered by {{name}} · {{date}}',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
275
frontend/src/pages/EnterPage.css
Normal file
275
frontend/src/pages/EnterPage.css
Normal file
@@ -0,0 +1,275 @@
|
||||
/* EnterPage: the threshold. Centered gothic auth panel over drifting CSS
|
||||
fog. Scoped under .enter-page. */
|
||||
|
||||
.enter-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07070d;
|
||||
color: #d8e6dd;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow: hidden;
|
||||
padding: 2rem 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------- smoky backdrop (pure CSS fog) ---------- */
|
||||
|
||||
.enter-fog {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.enter-fog::before,
|
||||
.enter-fog::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 65vmax;
|
||||
height: 65vmax;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.enter-fog::before {
|
||||
background: radial-gradient(circle, #7cffb2 0%, transparent 62%);
|
||||
top: -18%;
|
||||
left: -12%;
|
||||
animation: fog-drift-a 27s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.enter-fog::after {
|
||||
background: radial-gradient(circle, #b26bff 0%, transparent 62%);
|
||||
bottom: -22%;
|
||||
right: -14%;
|
||||
animation: fog-drift-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes fog-drift-a {
|
||||
from { transform: translate(0, 0) scale(1); }
|
||||
to { transform: translate(9vw, 6vh) scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes fog-drift-b {
|
||||
from { transform: translate(0, 0) scale(1.1); }
|
||||
to { transform: translate(-8vw, -7vh) scale(0.95); }
|
||||
}
|
||||
|
||||
/* dimmed vignette + scanlines */
|
||||
.enter-scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 40%, rgba(3, 3, 7, 0.75) 100%),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.022) 0,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- the panel ---------- */
|
||||
|
||||
.enter-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: min(92vw, 420px);
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 2.6rem 2.2rem 2rem;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow:
|
||||
0 0 70px rgba(0, 0, 0, 0.65),
|
||||
0 0 32px rgba(124, 255, 178, 0.07);
|
||||
}
|
||||
|
||||
.enter-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.7rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: #eafff3;
|
||||
text-shadow:
|
||||
0 0 16px rgba(124, 255, 178, 0.45),
|
||||
0 0 50px rgba(124, 255, 178, 0.15);
|
||||
animation: enter-flicker 6s infinite;
|
||||
}
|
||||
|
||||
@keyframes enter-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
93% { opacity: 1; }
|
||||
94% { opacity: 0.7; }
|
||||
95% { opacity: 1; }
|
||||
97% { opacity: 0.85; }
|
||||
98% { opacity: 1; }
|
||||
}
|
||||
|
||||
.enter-sub {
|
||||
margin: 0 0 1.9rem;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
/* ---------- mode toggle ---------- */
|
||||
|
||||
.enter-tabs {
|
||||
display: flex;
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
.enter-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.7rem 0;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.enter-tab:hover {
|
||||
color: #7cffb2;
|
||||
background: rgba(124, 255, 178, 0.06);
|
||||
}
|
||||
|
||||
.enter-tab.active,
|
||||
.enter-tab.active:hover {
|
||||
color: #07120c;
|
||||
background: #7cffb2;
|
||||
box-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.enter-tab:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ---------- form ---------- */
|
||||
|
||||
.enter-form {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.enter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.enter-field span {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.65);
|
||||
}
|
||||
|
||||
.enter-field input {
|
||||
background: rgba(7, 7, 13, 0.8);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 0;
|
||||
color: #eafff3;
|
||||
padding: 0.72rem 0.8rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.enter-field input:focus {
|
||||
border-color: #7cffb2;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(124, 255, 178, 0.35),
|
||||
0 0 18px rgba(124, 255, 178, 0.18);
|
||||
}
|
||||
|
||||
.enter-error {
|
||||
margin: 0 0 1.15rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: #ff3b5c;
|
||||
border: 1px solid rgba(255, 59, 92, 0.35);
|
||||
background: rgba(255, 59, 92, 0.07);
|
||||
text-shadow: 0 0 8px rgba(255, 59, 92, 0.35);
|
||||
animation: error-flicker 2.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes error-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
89% { opacity: 0.65; }
|
||||
90% { opacity: 1; }
|
||||
}
|
||||
|
||||
.enter-submit {
|
||||
width: 100%;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, #8dffc0, #4fd392);
|
||||
border: 1px solid #7cffb2;
|
||||
border-radius: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 22px rgba(124, 255, 178, 0.3),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
transition: box-shadow 0.25s ease, transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.enter-submit:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 40px rgba(124, 255, 178, 0.55),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
}
|
||||
|
||||
.enter-submit:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.enter-submit:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.enter-smallprint {
|
||||
margin: 1.6rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(216, 230, 221, 0.38);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.enter-fog::before,
|
||||
.enter-fog::after,
|
||||
.enter-title,
|
||||
.enter-error {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
129
frontend/src/pages/EnterPage.tsx
Normal file
129
frontend/src/pages/EnterPage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// Enter page: the threshold. Gothic auth panel floating in CSS fog —
|
||||
// toggles between Reconnect (login) and First contact (register), and
|
||||
// steps the medium through to the séance on success.
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import './EnterPage.css'
|
||||
|
||||
type EnterMode = 'login' | 'register'
|
||||
|
||||
export function EnterPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user, login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [mode, setMode] = useState<EnterMode>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Already seated at the table — no need to knock again.
|
||||
if (user) {
|
||||
return <Navigate to="/seance" replace />
|
||||
}
|
||||
|
||||
const switchMode = (next: EnterMode) => {
|
||||
setMode(next)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (busy) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
if (mode === 'register') {
|
||||
await register(username, password)
|
||||
} else {
|
||||
await login(username, password)
|
||||
}
|
||||
navigate('/seance')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="enter-page">
|
||||
<div className="enter-fog" aria-hidden="true" />
|
||||
<div className="enter-scanlines" aria-hidden="true" />
|
||||
|
||||
<main className="enter-panel">
|
||||
<h1 className="enter-title">{t('nav.home')}</h1>
|
||||
<p className="enter-sub">
|
||||
{mode === 'login' ? t('enter.reconnectSub') : t('enter.firstContactSub')}
|
||||
</p>
|
||||
|
||||
<div className="enter-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'login'}
|
||||
className={`enter-tab${mode === 'login' ? ' active' : ''}`}
|
||||
onClick={() => switchMode('login')}
|
||||
>
|
||||
{t('enter.reconnect')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'register'}
|
||||
className={`enter-tab${mode === 'register' ? ' active' : ''}`}
|
||||
onClick={() => switchMode('register')}
|
||||
>
|
||||
{t('enter.firstContact')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="enter-form" onSubmit={handleSubmit}>
|
||||
<label className="enter-field">
|
||||
<span>{t('enter.username')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="enter-field">
|
||||
<span>{t('enter.password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="enter-error" role="alert">
|
||||
{t('enter.failed', { message: error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" className="enter-submit" disabled={busy}>
|
||||
{mode === 'login' ? t('enter.submitLogin') : t('enter.submitRegister')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="enter-smallprint">
|
||||
{t('enter.smallPrint', {
|
||||
defaultValue: 'your medium handle is only ever stored on this server',
|
||||
})}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
466
frontend/src/pages/LandingPage.css
Normal file
466
frontend/src/pages/LandingPage.css
Normal file
@@ -0,0 +1,466 @@
|
||||
/* LandingPage: gothic x hacker front door. Scoped under .landing to avoid
|
||||
colliding with other pages' styles. */
|
||||
|
||||
.landing {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #07070d;
|
||||
color: #d8e6dd;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* scanline + vignette film over everything */
|
||||
.landing-scanlines {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.55) 100%),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smoke-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: lowercase;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.hero-kicker::before,
|
||||
.hero-kicker::after {
|
||||
content: '//';
|
||||
color: rgba(124, 255, 178, 0.4);
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
|
||||
.glitch-title {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
font-size: clamp(2.6rem, 11vw, 8.5rem);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
color: #eafff3;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.45),
|
||||
0 0 60px rgba(124, 255, 178, 0.18),
|
||||
0 0 120px rgba(178, 107, 255, 0.12);
|
||||
animation: title-flicker 5.2s infinite;
|
||||
}
|
||||
|
||||
.glitch-title::before,
|
||||
.glitch-title::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glitch-title::before {
|
||||
color: #7cffb2;
|
||||
animation: glitch-slice-a 2.9s infinite steps(1, end);
|
||||
}
|
||||
|
||||
.glitch-title::after {
|
||||
color: #b26bff;
|
||||
animation: glitch-slice-b 3.4s infinite steps(1, end);
|
||||
}
|
||||
|
||||
/* short jump-cut bursts, long eerie calm in between */
|
||||
@keyframes glitch-slice-a {
|
||||
0%, 84%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
|
||||
85% { opacity: 0.85; transform: translate(-7px, -3px); clip-path: inset(8% 0 64% 0); }
|
||||
88% { opacity: 0.85; transform: translate(5px, 2px); clip-path: inset(56% 0 20% 0); }
|
||||
91% { opacity: 0.85; transform: translate(-4px, 3px); clip-path: inset(32% 0 44% 0); }
|
||||
93% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes glitch-slice-b {
|
||||
0%, 78%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
|
||||
79% { opacity: 0.8; transform: translate(6px, 2px); clip-path: inset(62% 0 12% 0); }
|
||||
82% { opacity: 0.8; transform: translate(-5px, -2px); clip-path: inset(14% 0 70% 0); }
|
||||
85% { opacity: 0.8; transform: translate(3px, -3px); clip-path: inset(40% 0 36% 0); }
|
||||
87% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes title-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
91% { opacity: 1; }
|
||||
92% { opacity: 0.72; }
|
||||
93% { opacity: 1; }
|
||||
96% { opacity: 0.86; }
|
||||
97% { opacity: 1; }
|
||||
}
|
||||
|
||||
.hero-tagline-slot {
|
||||
min-height: 1.6em;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
display: inline-block;
|
||||
font-size: clamp(0.9rem, 2vw, 1.15rem);
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.35);
|
||||
animation: tagline-in 1.1s ease both;
|
||||
}
|
||||
|
||||
@keyframes tagline-in {
|
||||
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
|
||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.cta-primary {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, #8dffc0, #4fd392);
|
||||
border: 1px solid #7cffb2;
|
||||
padding: 0.95rem 2.4rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 24px rgba(124, 255, 178, 0.35),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
transition: box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.cta-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 44px rgba(124, 255, 178, 0.6),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
}
|
||||
|
||||
.cta-primary:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cta-secondary {
|
||||
color: #b26bff;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
|
||||
padding-bottom: 2px;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.cta-secondary:hover {
|
||||
color: #d3a8ff;
|
||||
border-color: #d3a8ff;
|
||||
text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
|
||||
}
|
||||
|
||||
/* ---------- shared section chrome ---------- */
|
||||
|
||||
.landing-section {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 5rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(1.2rem, 3vw, 1.8rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
color: #eafff3;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.3);
|
||||
margin: 0 0 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-title::before,
|
||||
.section-title::after {
|
||||
content: '';
|
||||
flex: 0 0 3rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(124, 255, 178, 0.4));
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
background: linear-gradient(90deg, rgba(124, 255, 178, 0.4), transparent);
|
||||
}
|
||||
|
||||
/* ---------- live veil activity ---------- */
|
||||
|
||||
.live-dot {
|
||||
flex: none;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: #7cffb2;
|
||||
box-shadow: 0 0 10px rgba(124, 255, 178, 0.9);
|
||||
animation: live-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: rgba(124, 255, 178, 0.18);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
box-shadow: 0 0 40px rgba(124, 255, 178, 0.06);
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
padding: 1.9rem 1rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: clamp(1.6rem, 4vw, 2.6rem);
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
}
|
||||
|
||||
/* ---------- how the veil answers ---------- */
|
||||
|
||||
.modes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 1.75rem 1.35rem;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
border-color: rgba(124, 255, 178, 0.55);
|
||||
box-shadow: 0 0 30px rgba(124, 255, 178, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.mode-glyph {
|
||||
color: #7cffb2;
|
||||
margin-bottom: 1rem;
|
||||
transition: color 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
|
||||
.mode-card:hover .mode-glyph {
|
||||
color: #b26bff;
|
||||
filter: drop-shadow(0 0 8px rgba(178, 107, 255, 0.6));
|
||||
}
|
||||
|
||||
.mode-glyph-svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: #eafff3;
|
||||
margin: 0 0 0.7rem;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: rgba(216, 230, 221, 0.62);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- featured spirits ---------- */
|
||||
|
||||
.featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.spirit-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 1.9rem 1.4rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.spirit-card:hover {
|
||||
border-color: rgba(178, 107, 255, 0.55);
|
||||
box-shadow: 0 0 32px rgba(178, 107, 255, 0.14);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.spirit-name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #eafff3;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.spirit-epithet {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
}
|
||||
|
||||
.rarity-badge {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid currentColor;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.spirit-card .rarity-common { color: rgba(124, 255, 178, 0.7); }
|
||||
.spirit-card .rarity-uncommon { color: #b26bff; }
|
||||
.spirit-card .rarity-rare { color: #b26bff; text-shadow: 0 0 10px rgba(178, 107, 255, 0.7); }
|
||||
.spirit-card .rarity-mythic { color: #ff3b5c; text-shadow: 0 0 10px rgba(255, 59, 92, 0.7); }
|
||||
|
||||
.spirit-quote {
|
||||
margin-top: 0.8rem;
|
||||
font-family: Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(216, 230, 221, 0.6);
|
||||
quotes: '\201C' '\201D';
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-empty {
|
||||
text-align: center;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
|
||||
.landing-footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
border-top: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(216, 230, 221, 0.5);
|
||||
}
|
||||
|
||||
.landing-footer p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.footer-colophon {
|
||||
color: rgba(124, 255, 178, 0.45);
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---------- responsive + reduced motion ---------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.section-title::before,
|
||||
.section-title::after {
|
||||
flex-basis: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glitch-title,
|
||||
.glitch-title::before,
|
||||
.glitch-title::after,
|
||||
.hero-tagline,
|
||||
.live-dot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.glitch-title::before,
|
||||
.glitch-title::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
347
frontend/src/pages/LandingPage.tsx
Normal file
347
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
// Landing page: the hype front door. Full-viewport glitch hero over a
|
||||
// self-contained drifting-smoke canvas, live veil-activity counters,
|
||||
// the four contact modes, featured spirits from the codex, and the CTA.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { GhostGlyph } from '../components/GhostGlyph'
|
||||
import type { CodexEntity, CodexListResponse, Mode, Stats } from '../lib/types'
|
||||
import './LandingPage.css'
|
||||
|
||||
const STATS_REFRESH_MS = 15_000
|
||||
const TAGLINE_MS = 4_000
|
||||
const MODE_ORDER: Mode[] = ['radio', 'evp', 'wire', 'ouija']
|
||||
const STAT_KEYS = ['entities', 'sessions', 'utterances', 'anomalies'] as const
|
||||
|
||||
/** Hacker-occult stroke glyphs for the four contact modes (no emoji, no fonts). */
|
||||
function ModeGlyph({ mode }: { mode: Mode }) {
|
||||
const common = {
|
||||
viewBox: '0 0 48 48',
|
||||
className: 'mode-glyph-svg',
|
||||
'aria-hidden': true as const,
|
||||
}
|
||||
switch (mode) {
|
||||
case 'radio':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="24" cy="18" r="3" />
|
||||
<path d="M24 21 L15 42 M24 21 L33 42 M18.6 33 h10.8" />
|
||||
<path d="M16.5 12.5 a10.5 10.5 0 0 1 15 0" />
|
||||
<path d="M11.5 7.5 a18 18 0 0 1 25 0" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'evp':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M7 24 h4 M37 24 h4" />
|
||||
<path d="M14 20 v8 M19 14 v20 M24 18 v12 M29 10 v28 M34 17 v14" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'wire':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M14.6 31.6 L21.7 16.6 M26.3 16.6 L33.4 29.6 M15.2 34 h17.6" />
|
||||
<circle cx="12" cy="34" r="3.2" />
|
||||
<circle cx="24" cy="13" r="3.2" />
|
||||
<circle cx="36" cy="32" r="3.2" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'ouija':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
|
||||
<path d="M24 6 C29 14 39 20 39 30 A15 15 0 1 1 9 30 C9 20 19 14 24 6 Z" />
|
||||
<circle cx="24" cy="29" r="5.5" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Wisp = {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
hue: number
|
||||
alpha: number
|
||||
vy: number
|
||||
swayAmp: number
|
||||
swaySpeed: number
|
||||
phase: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight 2D-canvas fog: slow phosphor-green/violet wisps drifting
|
||||
* upward on an additive blend. Self-contained; tears itself down on unmount.
|
||||
*/
|
||||
function SmokeCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let raf = 0
|
||||
let wisps: Wisp[] = []
|
||||
const HUES = [150, 275] // phosphor green / violet
|
||||
|
||||
const spawn = (anywhere: boolean): Wisp => {
|
||||
const r = 50 + Math.random() * 120
|
||||
return {
|
||||
x: Math.random() * canvas.width,
|
||||
y: anywhere ? Math.random() * canvas.height : canvas.height + r,
|
||||
r,
|
||||
hue: HUES[Math.floor(Math.random() * HUES.length)],
|
||||
alpha: 0.025 + Math.random() * 0.05,
|
||||
vy: 0.06 + Math.random() * 0.22,
|
||||
swayAmp: 0.1 + Math.random() * 0.35,
|
||||
swaySpeed: 0.0004 + Math.random() * 0.0009,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
}
|
||||
}
|
||||
|
||||
const seed = () => {
|
||||
const count = Math.max(14, Math.min(34, Math.floor((canvas.width * canvas.height) / 42000)))
|
||||
wisps = Array.from({ length: count }, () => spawn(true))
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.clientWidth
|
||||
canvas.height = canvas.clientHeight
|
||||
seed()
|
||||
}
|
||||
|
||||
const frame = (now: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
for (let i = 0; i < wisps.length; i++) {
|
||||
const w = wisps[i]
|
||||
w.y -= w.vy
|
||||
const x = w.x + Math.sin(now * w.swaySpeed + w.phase) * w.swayAmp * 40
|
||||
if (w.y + w.r < 0) {
|
||||
wisps[i] = spawn(false)
|
||||
continue
|
||||
}
|
||||
const g = ctx.createRadialGradient(x, w.y, 0, x, w.y, w.r)
|
||||
g.addColorStop(0, `hsla(${w.hue}, 90%, 65%, ${w.alpha})`)
|
||||
g.addColorStop(1, `hsla(${w.hue}, 90%, 65%, 0)`)
|
||||
ctx.fillStyle = g
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, w.y, w.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} className="smoke-canvas" aria-hidden="true" />
|
||||
}
|
||||
|
||||
/** Count-up number that eases toward `value`; renders '—' when unknown. */
|
||||
function AnimatedNumber({ value }: { value: number | null }) {
|
||||
const [display, setDisplay] = useState<number | null>(value)
|
||||
const shownRef = useRef<number | null>(value)
|
||||
|
||||
useEffect(() => {
|
||||
if (value === null) {
|
||||
shownRef.current = null
|
||||
setDisplay(null)
|
||||
return
|
||||
}
|
||||
const from = shownRef.current ?? 0
|
||||
if (from === value) {
|
||||
setDisplay(value)
|
||||
return
|
||||
}
|
||||
const start = performance.now()
|
||||
const duration = 900
|
||||
let raf = requestAnimationFrame(function tick(now: number) {
|
||||
const p = Math.min(1, (now - start) / duration)
|
||||
const eased = 1 - Math.pow(1 - p, 3)
|
||||
const v = Math.round(from + (value - from) * eased)
|
||||
shownRef.current = v
|
||||
setDisplay(v)
|
||||
if (p < 1) raf = requestAnimationFrame(tick)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [value])
|
||||
|
||||
return <span className="stat-value">{display === null ? '—' : display.toLocaleString()}</span>
|
||||
}
|
||||
|
||||
export function LandingPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [featured, setFeatured] = useState<CodexEntity[] | null>(null)
|
||||
const [taglineIdx, setTaglineIdx] = useState(0)
|
||||
|
||||
const taglineObjects: unknown = t('landing.taglines', { returnObjects: true })
|
||||
const taglines = Array.isArray(taglineObjects)
|
||||
? taglineObjects.filter((s): s is string => typeof s === 'string')
|
||||
: []
|
||||
const title = t('common.appName')
|
||||
|
||||
// Rotate the hero tagline; the key remount re-triggers the CSS fade.
|
||||
useEffect(() => {
|
||||
if (taglines.length < 2) return
|
||||
const id = window.setInterval(() => setTaglineIdx((i) => (i + 1) % taglines.length), TAGLINE_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [taglines.length])
|
||||
|
||||
// Live veil activity: poll the public stats endpoint, fail silently.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = () => {
|
||||
fetch('/api/stats', { credentials: 'include' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`stats ${r.status}`)
|
||||
return r.json() as Promise<Stats>
|
||||
})
|
||||
.then((s) => {
|
||||
if (!cancelled) setStats(s)
|
||||
})
|
||||
.catch(() => {
|
||||
/* the veil keeps its secrets — keep old numbers or '—' */
|
||||
})
|
||||
}
|
||||
load()
|
||||
const id = window.setInterval(load, STATS_REFRESH_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Featured spirits: most-contacted three from the codex. Hidden on failure.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/codex?sort=contacted&limit=3', { credentials: 'include' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`codex ${r.status}`)
|
||||
return r.json() as Promise<CodexListResponse>
|
||||
})
|
||||
.then((d) => {
|
||||
if (!cancelled) setFeatured(d.entities.slice(0, 3))
|
||||
})
|
||||
.catch(() => {
|
||||
/* registry unreachable — section simply stays shut */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="landing">
|
||||
<div className="landing-scanlines" aria-hidden="true" />
|
||||
|
||||
<section className="hero">
|
||||
<SmokeCanvas />
|
||||
<div className="hero-content">
|
||||
<p className="hero-kicker">{t('landing.heroKicker')}</p>
|
||||
<h1 className="glitch-title" data-text={title}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="hero-tagline-slot">
|
||||
{taglines.length > 0 && (
|
||||
<span key={taglineIdx} className="hero-tagline">
|
||||
{taglines[taglineIdx % taglines.length]}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="hero-ctas">
|
||||
<button
|
||||
type="button"
|
||||
className="cta-primary"
|
||||
onClick={() => navigate(user ? '/seance' : '/enter')}
|
||||
>
|
||||
{t('landing.cta')}
|
||||
</button>
|
||||
<Link className="cta-secondary" to="/codex">
|
||||
{t('landing.ctaCodex')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-section veil-stats" aria-label={t('landing.tickerPrefix')}>
|
||||
<h2 className="section-title">
|
||||
<span className="live-dot" aria-hidden="true" />
|
||||
{t('landing.tickerPrefix')}
|
||||
</h2>
|
||||
<div className="stats-grid">
|
||||
{STAT_KEYS.map((key) => (
|
||||
<div className="stat-cell" key={key}>
|
||||
<AnimatedNumber value={stats ? stats[key] : null} />
|
||||
<span className="stat-label">{t(`landing.stats.${key}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-section modes">
|
||||
<h2 className="section-title">{t('landing.modesTitle')}</h2>
|
||||
<div className="modes-grid">
|
||||
{MODE_ORDER.map((mode) => (
|
||||
<article className="mode-card" key={mode}>
|
||||
<div className="mode-glyph">
|
||||
<ModeGlyph mode={mode} />
|
||||
</div>
|
||||
<h3 className="mode-name">{t(`landing.modes.${mode}.name`)}</h3>
|
||||
<p className="mode-desc">{t(`landing.modes.${mode}.desc`)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{featured !== null && (
|
||||
<section className="landing-section featured">
|
||||
<h2 className="section-title">{t('landing.featured')}</h2>
|
||||
{featured.length === 0 ? (
|
||||
<p className="featured-empty">{t('landing.featuredEmpty')}</p>
|
||||
) : (
|
||||
<div className="featured-grid">
|
||||
{featured.map((e) => (
|
||||
<Link to={`/codex/${e.id}`} className="spirit-card" key={e.id}>
|
||||
<GhostGlyph hue={e.visual.hue} form={e.visual.form} size={56} />
|
||||
<span className="spirit-name">{e.name}</span>
|
||||
<span className="spirit-epithet">{e.epithet}</span>
|
||||
<span className={`rarity-badge rarity-${e.rarity}`}>
|
||||
{t(`codex.rarity.${e.rarity}`)}
|
||||
</span>
|
||||
{e.quotes.length > 0 && <q className="spirit-quote">{e.quotes[0]}</q>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="landing-footer">
|
||||
<p>{t('landing.footer')}</p>
|
||||
<p className="footer-colophon">{t('landing.footerColophon')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1263
frontend/src/pages/SeancePage.css
Normal file
1263
frontend/src/pages/SeancePage.css
Normal file
File diff suppressed because it is too large
Load Diff
819
frontend/src/pages/SeancePage.tsx
Normal file
819
frontend/src/pages/SeancePage.tsx
Normal file
@@ -0,0 +1,819 @@
|
||||
// The main séance screen: ghost backdrop, planchette board, mode panels
|
||||
// (wire / evp / radio / ouija), transmission log and direct-contact row.
|
||||
//
|
||||
// Wiring notes:
|
||||
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
|
||||
// itself; this page only enqueues utterance text and polls snapshot()
|
||||
// (non-mutating) for the ouija panel readout.
|
||||
// - Each mode panel is conditionally mounted, so mic / SDR teardown happens
|
||||
// in the panel's unmount cleanup — switching modes or leaving the page
|
||||
// always releases the hardware.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { SeanceProvider, useSeance } from '../state/seance'
|
||||
import type { Toast } from '../state/seance'
|
||||
import { GhostCanvas } from '../three/GhostCanvas'
|
||||
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
|
||||
import { PlanchetteBoard } from '../components/PlanchetteBoard'
|
||||
import { Transcript } from '../components/Transcript'
|
||||
import { PlanchetteMachine } from '../lib/planchette'
|
||||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||||
import { EvpListener } from '../lib/evp'
|
||||
import {
|
||||
isSupported,
|
||||
RtlSdr,
|
||||
SpectrumAnomalyDetector,
|
||||
SWEEP_END_MHZ,
|
||||
SWEEP_START_MHZ,
|
||||
} from '../lib/sdr'
|
||||
import { persistLanguage, storedLanguage } from '../i18n'
|
||||
import type { Language, Mode } from '../lib/types'
|
||||
import './SeancePage.css'
|
||||
|
||||
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
|
||||
const TOAST_TTL_MS = 6000
|
||||
/** SDR sample rate must match RtlSdr.open()'s default. */
|
||||
const SDR_SAMPLE_RATE_HZ = 2_048_000
|
||||
|
||||
export function SeancePage() {
|
||||
const { user, checking } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="seance-loading" role="status">
|
||||
<div className="loading-sigil" aria-hidden>
|
||||
⟁
|
||||
</div>
|
||||
<p className="loading-text">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
return (
|
||||
<SeanceProvider>
|
||||
<SeanceSession username={user.username} />
|
||||
</SeanceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SeancePage
|
||||
|
||||
// ---- the séance itself (rendered inside SeanceProvider) ----
|
||||
|
||||
function SeanceSession({ username }: { username: string }) {
|
||||
const {
|
||||
state,
|
||||
audioPlayer,
|
||||
setMode,
|
||||
setPassive,
|
||||
setLanguage,
|
||||
summon,
|
||||
ask,
|
||||
playUtterance,
|
||||
dismissToast,
|
||||
} = useSeance()
|
||||
const { logout } = useAuth()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const [departed, setDeparted] = useState(false)
|
||||
const [question, setQuestion] = useState('')
|
||||
const [dossierOpen, setDossierOpen] = useState(true)
|
||||
|
||||
// The planchette machine lives for the whole session; the board ticks it.
|
||||
const machineRef = useRef(new PlanchetteMachine())
|
||||
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(null)
|
||||
|
||||
// Tell the backend which tongue we whisper in (queued while connecting).
|
||||
useEffect(() => {
|
||||
setLanguage(storedLanguage())
|
||||
}, [setLanguage])
|
||||
|
||||
// Feed every fresh utterance (greetings, fragments, replies) to the board.
|
||||
const lastEnqueuedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
const uts = state.utterances
|
||||
if (uts.length === 0) return
|
||||
const lastSeen = lastEnqueuedRef.current
|
||||
const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen)
|
||||
if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history
|
||||
const fresh = uts.slice(idx + 1)
|
||||
for (const u of fresh) machineRef.current.enqueue(u.text)
|
||||
lastEnqueuedRef.current = uts[uts.length - 1].id
|
||||
}, [state.utterances])
|
||||
|
||||
// Poll the machine's snapshot for the ouija readout (board owns ticking).
|
||||
useEffect(() => {
|
||||
let lastSig = ''
|
||||
const id = window.setInterval(() => {
|
||||
const s = machineRef.current.snapshot()
|
||||
const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}`
|
||||
if (sig !== lastSig) {
|
||||
lastSig = sig
|
||||
setBoardSnap(s)
|
||||
}
|
||||
}, 150)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Open the dossier whenever a new presence steps through.
|
||||
const entityId = state.entity?.id ?? null
|
||||
useEffect(() => {
|
||||
if (entityId) setDossierOpen(true)
|
||||
}, [entityId])
|
||||
|
||||
const ghostVisual = useMemo<GhostVisualState | null>(
|
||||
() =>
|
||||
state.entity
|
||||
? { hue: state.entity.visual.hue, form: state.entity.visual.form }
|
||||
: null,
|
||||
[state.entity],
|
||||
)
|
||||
|
||||
const ghostMood: GhostMood = state.speakingId
|
||||
? 'speaking'
|
||||
: state.status === 'gathering' || state.status === 'summoning'
|
||||
? 'gathering'
|
||||
: state.status === 'attuning'
|
||||
? 'attuning'
|
||||
: 'idle'
|
||||
|
||||
const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer])
|
||||
|
||||
const switchLanguage = (lang: Language) => {
|
||||
setLanguage(lang)
|
||||
void i18n.changeLanguage(lang)
|
||||
persistLanguage(lang)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logout()
|
||||
.catch(() => undefined)
|
||||
.finally(() => setDeparted(true))
|
||||
}
|
||||
|
||||
const streaming = state.replyStreaming.active
|
||||
const submitQuestion = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (streaming) return
|
||||
ask(question)
|
||||
setQuestion('')
|
||||
}
|
||||
|
||||
if (departed) return <Navigate to="/" replace />
|
||||
|
||||
const entityHue = state.entity?.visual.hue ?? 150
|
||||
const entity = state.entity
|
||||
|
||||
return (
|
||||
<div className="seance-page">
|
||||
<GhostCanvas
|
||||
className="seance-ghost"
|
||||
visual={ghostVisual}
|
||||
mood={ghostMood}
|
||||
getAmplitude={getAmplitude}
|
||||
/>
|
||||
|
||||
{/* ---- top bar ---- */}
|
||||
<header className="seance-topbar">
|
||||
<div className="seance-brand">
|
||||
<span className="brand-sigil" aria-hidden>
|
||||
⟁
|
||||
</span>
|
||||
{t('common.appName')}
|
||||
</div>
|
||||
|
||||
<nav className="seance-modes" aria-label="modes">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{t(`seance.modes.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="seance-top-right">
|
||||
<label className="passive-toggle" title={t('seance.passive')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.passive}
|
||||
onChange={(e) => setPassive(e.target.checked)}
|
||||
/>
|
||||
<span className="passive-track" aria-hidden />
|
||||
<span className="passive-label">{t('seance.passive')}</span>
|
||||
</label>
|
||||
|
||||
<div className="lang-toggle" role="group" aria-label="language">
|
||||
<button
|
||||
type="button"
|
||||
className={state.language === 'en' ? 'active' : ''}
|
||||
onClick={() => switchLanguage('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={state.language === 'es' ? 'active' : ''}
|
||||
onClick={() => switchLanguage('es')}
|
||||
>
|
||||
ES
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link to="/codex" className="seance-codex-link">
|
||||
{t('nav.codex')}
|
||||
</Link>
|
||||
|
||||
<span className="seance-user" title={username}>
|
||||
{username}
|
||||
</span>
|
||||
<button type="button" className="seance-logout" onClick={handleLogout}>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={`conn-dot conn-${state.connection}`}
|
||||
title={t(`seance.connection.${state.connection}`)}
|
||||
aria-label={t(`seance.connection.${state.connection}`)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ---- unstable-veil banner ---- */}
|
||||
{state.connection !== 'open' && (
|
||||
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
|
||||
<span className="conn-glyph" aria-hidden>
|
||||
⟁
|
||||
</span>
|
||||
{t(`seance.connection.${state.connection}`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- main: board + side column ---- */}
|
||||
<main className="seance-main">
|
||||
<section className="seance-board-wrap">
|
||||
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
|
||||
|
||||
{state.status && (
|
||||
<div className="seance-status-overlay" aria-live="polite">
|
||||
<span className="status-sigil" aria-hidden />
|
||||
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="seance-side">
|
||||
{entity ? (
|
||||
<section className={`spirit-dossier ${dossierOpen ? 'open' : 'closed'}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="dossier-header"
|
||||
onClick={() => setDossierOpen((o) => !o)}
|
||||
aria-expanded={dossierOpen}
|
||||
>
|
||||
<span className="dossier-title">{t('seance.entity.title')}</span>
|
||||
<span className="dossier-chevron" aria-hidden>
|
||||
{dossierOpen ? '▾' : '◂'}
|
||||
</span>
|
||||
</button>
|
||||
{dossierOpen && (
|
||||
<div className="dossier-body">
|
||||
<h3 className="dossier-name" style={{ color: `hsl(${entity.visual.hue} 85% 78%)` }}>
|
||||
{entity.name}
|
||||
</h3>
|
||||
<p className="dossier-epithet">{entity.epithet}</p>
|
||||
<div className="dossier-badges">
|
||||
<span className={`rarity-badge rarity-${entity.rarity}`}>
|
||||
{t(`seance.entity.rarity.${entity.rarity}`)}
|
||||
</span>
|
||||
{state.entityIsNew ? (
|
||||
<span className="dossier-badge badge-new">{t('seance.entity.isNew')}</span>
|
||||
) : (
|
||||
<span className="dossier-badge badge-known">
|
||||
{t('seance.entity.known', { count: entity.contact_count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.persona')}</h4>
|
||||
<p className="dossier-persona">{entity.persona}</p>
|
||||
</div>
|
||||
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.voice')}</h4>
|
||||
<dl className="voice-params">
|
||||
<div>
|
||||
<dt>id</dt>
|
||||
<dd>{entity.voice.voice_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>pitch</dt>
|
||||
<dd>{entity.voice.pitch.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>rate</dt>
|
||||
<dd>{entity.voice.rate.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>noise</dt>
|
||||
<dd>{entity.voice.noise.toFixed(2)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>echo</dt>
|
||||
<dd>{entity.voice.echo.toFixed(2)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{entity.quotes.length > 0 && (
|
||||
<div className="dossier-section">
|
||||
<h4>{t('seance.entity.quotes')}</h4>
|
||||
<ul className="dossier-quotes">
|
||||
{entity.quotes.slice(0, 4).map((q, i) => (
|
||||
<li key={i}>“{q}”</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="spirit-dossier empty">
|
||||
<div className="dossier-body">
|
||||
<p className="dossier-none">{t('seance.entity.none')}</p>
|
||||
<p className="dossier-nonehint dim">{t('seance.entity.noneHint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mode-panel">
|
||||
{state.mode === 'wire' && <WirePanel />}
|
||||
{state.mode === 'evp' && <EvpPanel />}
|
||||
{state.mode === 'radio' && <RadioPanel />}
|
||||
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
{/* ---- bottom: transmission log + direct contact ---- */}
|
||||
<footer className="seance-bottom">
|
||||
<div className="seance-transcript-wrap">
|
||||
<div className="seance-transcript-title">
|
||||
<span>{t('seance.transcriptTitle')}</span>
|
||||
<span className="anomaly-count">
|
||||
{t('seance.anomaly.count', { count: state.anomalyCount })}
|
||||
</span>
|
||||
</div>
|
||||
<Transcript
|
||||
entries={state.transcript}
|
||||
streaming={state.replyStreaming}
|
||||
speakingId={state.speakingId}
|
||||
onReplay={playUtterance}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="seance-contact" onSubmit={submitQuestion}>
|
||||
<input
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder={t('seance.askPlaceholder')}
|
||||
disabled={streaming}
|
||||
maxLength={280}
|
||||
aria-label={t('seance.ask')}
|
||||
/>
|
||||
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
|
||||
{t('seance.ask')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="summon-btn"
|
||||
onClick={summon}
|
||||
disabled={state.status === 'summoning'}
|
||||
>
|
||||
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
|
||||
</button>
|
||||
</form>
|
||||
</footer>
|
||||
|
||||
{/* ---- toasts ---- */}
|
||||
<div className="seance-toasts" aria-live="polite">
|
||||
{state.toasts.map((toast) => (
|
||||
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ---- CRT overlays (above everything, never interactive) ---- */}
|
||||
<div className="seance-scanlines" aria-hidden />
|
||||
<div className="seance-vignette" aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- toasts ----
|
||||
|
||||
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
|
||||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [toast.id, onDismiss])
|
||||
|
||||
return (
|
||||
<div className="seance-toast" role="alert">
|
||||
<span className="toast-glyph" aria-hidden>
|
||||
⚠
|
||||
</span>
|
||||
<span className="toast-msg">
|
||||
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
aria-label={t('common.dismiss')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- wire panel: raw line telemetry ----
|
||||
|
||||
function WirePanel() {
|
||||
const { state } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const tel = state.telemetry
|
||||
|
||||
return (
|
||||
<div className="wire-panel">
|
||||
<h4 className="panel-title">{t('seance.telemetry.title')}</h4>
|
||||
{tel ? (
|
||||
<div className="telemetry-grid">
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.jitter')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.jitter_bytes_per_s.toFixed(1)} {t('seance.telemetry.units.bytes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.latencyVar')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.latency_variance_ms.toFixed(2)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.latencyMean')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.latency_mean_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tel-row">
|
||||
<span className="tel-key">{t('seance.telemetry.dns')}</span>
|
||||
<span className="tel-val flicker-soft">
|
||||
{tel.dns_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dim flicker">{t('common.loading')}</p>
|
||||
)}
|
||||
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
|
||||
{!state.passive && (
|
||||
<p className="wire-hint dim">
|
||||
{t('seance.wire.hint', {
|
||||
defaultValue:
|
||||
'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- EVP panel: microphone voice-band listening ----
|
||||
|
||||
function EvpPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const [listening, setListening] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
const [bandDb, setBandDb] = useState<number | null>(null)
|
||||
const listenerRef = useRef<EvpListener | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const lastMeterRef = useRef(0)
|
||||
|
||||
// Release the microphone when the panel unmounts (mode switch / leaving).
|
||||
useEffect(
|
||||
() => () => {
|
||||
const l = listenerRef.current
|
||||
listenerRef.current = null
|
||||
if (l) void l.stop()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const drawScope = (db: Float32Array) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const n = db.length
|
||||
const step = Math.max(1, Math.floor(n / 160))
|
||||
const bw = w / (n / step)
|
||||
for (let i = 0, x = 0; i < n; i += step, x++) {
|
||||
const v = db[i] // dB, roughly -160..0
|
||||
const norm = Math.max(0, Math.min(1, (v + 110) / 80))
|
||||
const bh = Math.max(1, norm * h)
|
||||
ctx.fillStyle = `hsla(150 90% 65% / ${0.25 + norm * 0.6})`
|
||||
ctx.fillRect(x * bw, h - bh, Math.max(1, bw - 0.5), bh)
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
if (pending || listening) return
|
||||
setDenied(false)
|
||||
setPending(true)
|
||||
const listener = listenerRef.current ?? new EvpListener()
|
||||
listenerRef.current = listener
|
||||
try {
|
||||
await listener.start({
|
||||
onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude),
|
||||
onFrame: (db) => {
|
||||
drawScope(db)
|
||||
const now = performance.now()
|
||||
if (now - lastMeterRef.current > 200) {
|
||||
lastMeterRef.current = now
|
||||
// Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT).
|
||||
const n = db.length
|
||||
const lo = Math.floor((300 / 24000) * n)
|
||||
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n))
|
||||
let peak = -Infinity
|
||||
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
|
||||
setBandDb(Number.isFinite(peak) ? peak : null)
|
||||
}
|
||||
},
|
||||
})
|
||||
if (listenerRef.current === listener) setListening(true)
|
||||
} catch {
|
||||
if (listenerRef.current === listener) listenerRef.current = null
|
||||
setDenied(true)
|
||||
setBandDb(null)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
const l = listenerRef.current
|
||||
listenerRef.current = null
|
||||
if (l) await l.stop()
|
||||
setListening(false)
|
||||
setBandDb(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evp-panel">
|
||||
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
|
||||
<div className="evp-readout">
|
||||
<span className="dim">{t('seance.evp.band')}</span>
|
||||
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
|
||||
</div>
|
||||
{listening && (
|
||||
<p className="evp-listening flicker">
|
||||
<span className="rec-dot" aria-hidden />
|
||||
{t('seance.evp.listening')}
|
||||
</p>
|
||||
)}
|
||||
{denied && (
|
||||
<div className="panel-notice error">
|
||||
<h4>{t('seance.evp.deniedTitle')}</h4>
|
||||
<p>{t('seance.evp.denied')}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="panel-action"
|
||||
onClick={() => void (listening ? stop() : start())}
|
||||
disabled={pending}
|
||||
>
|
||||
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- radio panel: RTL-SDR FM sweep ----
|
||||
|
||||
function RadioPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const supported = useMemo(() => isSupported(), [])
|
||||
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
|
||||
const sdrRef = useRef<RtlSdr | null>(null)
|
||||
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const lastTuneUiRef = useRef(0)
|
||||
|
||||
// Power down the dongle on unmount (mode switch / leaving the page).
|
||||
useEffect(
|
||||
() => () => {
|
||||
const sdr = sdrRef.current
|
||||
sdrRef.current = null
|
||||
detectorRef.current = null
|
||||
if (sdr) {
|
||||
sdr.stopSweep()
|
||||
void sdr.close()
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const drawWaterfallRow = (db: Float64Array) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
// Scroll the existing waterfall down one row, paint the new one on top.
|
||||
ctx.drawImage(canvas, 0, 1)
|
||||
const n = db.length
|
||||
const bw = w / n
|
||||
for (let i = 0; i < n; i++) {
|
||||
const norm = Math.max(0, Math.min(1, (db[i] + 90) / 70))
|
||||
ctx.fillStyle = `hsl(${272 - norm * 122} ${45 + norm * 50}% ${5 + norm * 52}%)`
|
||||
ctx.fillRect(i * bw, 0, Math.ceil(bw), 1)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const sdr = sdrRef.current
|
||||
sdrRef.current = null
|
||||
detectorRef.current = null
|
||||
if (sdr) {
|
||||
sdr.stopSweep()
|
||||
void sdr.close()
|
||||
}
|
||||
setPhase('idle')
|
||||
setTunedMhz(null)
|
||||
}, [])
|
||||
|
||||
const start = async () => {
|
||||
if (!supported || sdrRef.current || phase === 'busy') return
|
||||
setError(null)
|
||||
setPhase('busy')
|
||||
const sdr = new RtlSdr()
|
||||
try {
|
||||
await sdr.requestDevice()
|
||||
await sdr.open()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const name = err instanceof DOMException ? err.name : ''
|
||||
setError(
|
||||
name === 'NotFoundError' || /no device selected/i.test(msg)
|
||||
? t('seance.radio.noDevice')
|
||||
: /claim/i.test(msg)
|
||||
? t('seance.radio.claimFailed')
|
||||
: msg,
|
||||
)
|
||||
setPhase('idle')
|
||||
void sdr.close()
|
||||
return
|
||||
}
|
||||
sdrRef.current = sdr
|
||||
detectorRef.current = new SpectrumAnomalyDetector()
|
||||
setPhase('sweeping')
|
||||
void sdr
|
||||
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
|
||||
onSpectrum: (centerHz, db) => {
|
||||
if (sdrRef.current !== sdr) return
|
||||
drawWaterfallRow(db)
|
||||
const now = performance.now()
|
||||
if (now - lastTuneUiRef.current > 250) {
|
||||
lastTuneUiRef.current = now
|
||||
setTunedMhz(centerHz / 1e6)
|
||||
}
|
||||
const det = detectorRef.current
|
||||
if (det) {
|
||||
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now)
|
||||
if (a) sendAnomaly('radio', a.frequency, a.magnitude)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
if (sdrRef.current === sdr) setError(err.message)
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (sdrRef.current === sdr) setPhase('idle')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (sdrRef.current === sdr) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setPhase('idle')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="panel-notice">
|
||||
<h4>{t('seance.radio.unsupportedTitle')}</h4>
|
||||
<p>{t('seance.radio.unsupported')}</p>
|
||||
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
|
||||
<button type="button" className="panel-action" disabled>
|
||||
{t('seance.radio.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="radio-panel">
|
||||
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
|
||||
<div className="radio-readout">
|
||||
{tunedMhz !== null ? (
|
||||
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
|
||||
) : (
|
||||
<span className="dim">—</span>
|
||||
)}
|
||||
{phase === 'sweeping' && (
|
||||
<span className="dim flicker">
|
||||
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="panel-notice error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="panel-action"
|
||||
onClick={() => void (phase === 'sweeping' ? stop() : start())}
|
||||
disabled={phase === 'busy'}
|
||||
>
|
||||
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- ouija panel: what the planchette is doing ----
|
||||
|
||||
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
|
||||
const { t } = useTranslation()
|
||||
const word = snapshot?.word ?? null
|
||||
|
||||
return (
|
||||
<div className="ouija-panel">
|
||||
{word ? (
|
||||
<div className="ouija-spelling">
|
||||
<span className="dim ouija-spelling-label">
|
||||
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
|
||||
</span>
|
||||
<div className="ouija-word" aria-live="polite">
|
||||
{word.split('').map((ch, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
|
||||
}
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dim ouija-drift">
|
||||
{t('seance.ouija.drift', {
|
||||
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{snapshot && snapshot.spelled.length > 0 && (
|
||||
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
frontend/src/state/auth.tsx
Normal file
61
frontend/src/state/auth.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Auth context: who is seated at the table. Wraps the /auth REST endpoints
|
||||
// and exposes { user, checking, login, register, logout } to the app.
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import * as api from '../api'
|
||||
import type { User } from '../api'
|
||||
|
||||
export type AuthApi = {
|
||||
user: User | null
|
||||
checking: boolean
|
||||
login: (username: string, password: string) => Promise<User>
|
||||
register: (username: string, password: string) => Promise<User>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthApi | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const u = await api.login(username, password)
|
||||
setUser(u)
|
||||
return u
|
||||
}, [])
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
await api.register(username, password)
|
||||
return login(username, password)
|
||||
},
|
||||
[login],
|
||||
)
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<AuthApi>(
|
||||
() => ({ user, checking, login, register, logout }),
|
||||
[user, checking, login, register, logout],
|
||||
)
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): AuthApi {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
257
frontend/src/state/seance.test.ts
Normal file
257
frontend/src/state/seance.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { initialSeanceState, seanceReducer } from './seance'
|
||||
import type { SeanceState } from './seance'
|
||||
import type { ServerFrame, SpiritEntity, Telemetry } from '../lib/types'
|
||||
|
||||
const AT = 1_700_000_000_000
|
||||
|
||||
const entity: SpiritEntity = {
|
||||
id: 'e-1',
|
||||
name: 'Mirela',
|
||||
epithet: 'the Drowned',
|
||||
persona: 'A soft-spoken drowned girl.',
|
||||
rarity: 'rare',
|
||||
voice: { voice_id: 'v-1', pitch: 0.8, rate: 0.9, noise: 0.3, echo: 0.6 },
|
||||
visual: { hue: 210, form: 'wisp' },
|
||||
quotes: ['the water remembers'],
|
||||
contact_count: 4,
|
||||
discovered_at: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
function frame(state: SeanceState, f: ServerFrame, at = AT): SeanceState {
|
||||
return seanceReducer(state, { type: 'frame', frame: f, at })
|
||||
}
|
||||
|
||||
function lastRow(state: SeanceState) {
|
||||
return state.transcript[state.transcript.length - 1]
|
||||
}
|
||||
|
||||
describe('seanceReducer', () => {
|
||||
it('stores the session id on a session frame', () => {
|
||||
const next = frame(initialSeanceState, { type: 'session', id: 's-1' })
|
||||
expect(next.sessionId).toBe('s-1')
|
||||
expect(initialSeanceState.sessionId).toBeNull() // reducer is pure
|
||||
})
|
||||
|
||||
it('sets the entity, clears status and logs a system row on entity frames', () => {
|
||||
let state = frame(initialSeanceState, { type: 'status', state: 'summoning' })
|
||||
expect(state.status).toBe('summoning')
|
||||
|
||||
state = frame(state, { type: 'entity', is_new: true, entity })
|
||||
expect(state.entity).toEqual(entity)
|
||||
expect(state.entityIsNew).toBe(true)
|
||||
expect(state.status).toBeNull()
|
||||
|
||||
const row = lastRow(state)
|
||||
expect(row.kind).toBe('system')
|
||||
if (row.kind === 'system') {
|
||||
expect(row.text).toBe('⟁ Mirela — the Drowned')
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs an audio frame with its utterance in both the store and the transcript', () => {
|
||||
let state = frame(initialSeanceState, {
|
||||
type: 'utterance',
|
||||
id: 'u-1',
|
||||
kind: 'greeting',
|
||||
text: 'we are here',
|
||||
entity: 'Mirela',
|
||||
})
|
||||
expect(state.utterances).toHaveLength(1)
|
||||
expect(state.utterances[0]).toMatchObject({
|
||||
id: 'u-1',
|
||||
kind: 'greeting',
|
||||
text: 'we are here',
|
||||
entity: 'Mirela',
|
||||
audioUrl: null,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'utterance',
|
||||
utteranceId: 'u-1',
|
||||
speaker: 'Mirela',
|
||||
utKind: 'greeting',
|
||||
audioUrl: null,
|
||||
})
|
||||
|
||||
state = frame(state, { type: 'audio', id: 'u-1', url: '/audio/u-1.mp3' })
|
||||
expect(state.utterances[0].audioUrl).toBe('/audio/u-1.mp3')
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'utterance',
|
||||
utteranceId: 'u-1',
|
||||
audioUrl: '/audio/u-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores audio frames for unknown utterances', () => {
|
||||
const state = frame(initialSeanceState, { type: 'audio', id: 'ghost', url: '/audio/x.mp3' })
|
||||
expect(state.utterances).toEqual([])
|
||||
expect(state.transcript).toEqual([])
|
||||
})
|
||||
|
||||
it('assembles a streaming reply and lands it as a reply utterance', () => {
|
||||
let state = frame(initialSeanceState, { type: 'entity', is_new: false, entity })
|
||||
|
||||
state = frame(state, { type: 'reply_start' })
|
||||
expect(state.replyStreaming).toEqual({ active: true, text: '' })
|
||||
|
||||
state = frame(state, { type: 'reply_token', token: 'the veil ' })
|
||||
state = frame(state, { type: 'reply_token', token: 'is thin' })
|
||||
expect(state.replyStreaming).toEqual({ active: true, text: 'the veil is thin' })
|
||||
expect(state.utterances).toEqual([]) // nothing committed while streaming
|
||||
|
||||
state = frame(state, { type: 'reply_end', id: 'u-9', text: 'the veil is thin' })
|
||||
expect(state.replyStreaming).toEqual({ active: false, text: '' })
|
||||
expect(state.utterances).toHaveLength(1)
|
||||
expect(state.utterances[0]).toMatchObject({
|
||||
id: 'u-9',
|
||||
kind: 'reply',
|
||||
text: 'the veil is thin',
|
||||
entity: 'Mirela',
|
||||
audioUrl: null,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'utterance',
|
||||
utteranceId: 'u-9',
|
||||
utKind: 'reply',
|
||||
text: 'the veil is thin',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a null speaker for replies without a summoned entity', () => {
|
||||
let state = frame(initialSeanceState, { type: 'reply_start' })
|
||||
state = frame(state, { type: 'reply_end', id: 'u-1', text: 'boo' })
|
||||
expect(state.utterances[0].entity).toBeNull()
|
||||
})
|
||||
|
||||
it('stores telemetry values', () => {
|
||||
const telemetry: Telemetry = {
|
||||
jitter_bytes_per_s: 12.5,
|
||||
latency_variance_ms: 3.2,
|
||||
latency_mean_ms: 41.5,
|
||||
dns_ms: 7,
|
||||
}
|
||||
const state = frame(initialSeanceState, { type: 'telemetry', ...telemetry })
|
||||
expect(state.telemetry).toEqual(telemetry)
|
||||
})
|
||||
|
||||
it('appends error toasts and caps them at four, dropping the oldest', () => {
|
||||
let state = frame(initialSeanceState, {
|
||||
type: 'error',
|
||||
code: 'rate_limited',
|
||||
message: 'slow down, medium',
|
||||
})
|
||||
expect(state.toasts).toHaveLength(1)
|
||||
expect(state.toasts[0]).toMatchObject({ code: 'rate_limited', message: 'slow down, medium' })
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
state = frame(state, { type: 'error', code: `e${i}`, message: `m${i}` })
|
||||
}
|
||||
expect(state.toasts).toHaveLength(4)
|
||||
expect(state.toasts.map((t) => t.code)).toEqual(['e1', 'e2', 'e3', 'e4'])
|
||||
})
|
||||
|
||||
it('records local anomalies in both the anomalies list and the transcript', () => {
|
||||
const state = seanceReducer(initialSeanceState, {
|
||||
type: 'local_anomaly',
|
||||
source: 'evp',
|
||||
frequency: 1171.9,
|
||||
magnitude: 12.3,
|
||||
at: AT,
|
||||
})
|
||||
expect(state.anomalies).toHaveLength(1)
|
||||
expect(state.anomalies[0]).toMatchObject({
|
||||
source: 'evp',
|
||||
frequency: 1171.9,
|
||||
magnitude: 12.3,
|
||||
at: AT,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'anomaly',
|
||||
source: 'evp',
|
||||
frequency: 1171.9,
|
||||
magnitude: 12.3,
|
||||
})
|
||||
// The server-side count only moves when anomaly_ack arrives.
|
||||
expect(state.anomalyCount).toBe(0)
|
||||
})
|
||||
|
||||
it('appends local questions to the transcript', () => {
|
||||
const state = seanceReducer(initialSeanceState, {
|
||||
type: 'local_question',
|
||||
text: 'who are you',
|
||||
at: AT,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({ kind: 'question', text: 'who are you', at: AT })
|
||||
})
|
||||
|
||||
it('tracks the server anomaly count via anomaly_ack', () => {
|
||||
const state = frame(initialSeanceState, { type: 'anomaly_ack', count: 7 })
|
||||
expect(state.anomalyCount).toBe(7)
|
||||
})
|
||||
|
||||
it('caps the transcript at 400 entries, dropping the oldest', () => {
|
||||
let state = initialSeanceState
|
||||
for (let i = 1; i <= 410; i++) {
|
||||
state = seanceReducer(state, { type: 'local_question', text: `q${i}`, at: AT + i })
|
||||
}
|
||||
expect(state.transcript).toHaveLength(400)
|
||||
expect(state.transcript[0]).toMatchObject({ kind: 'question', text: 'q11' })
|
||||
expect(lastRow(state)).toMatchObject({ kind: 'question', text: 'q410' })
|
||||
})
|
||||
|
||||
it('caps the utterance store at 200 entries', () => {
|
||||
let state = initialSeanceState
|
||||
for (let i = 1; i <= 205; i++) {
|
||||
state = frame(
|
||||
state,
|
||||
{ type: 'utterance', id: `u${i}`, kind: 'ambient', text: `t${i}`, entity: null },
|
||||
AT + i,
|
||||
)
|
||||
}
|
||||
expect(state.utterances).toHaveLength(200)
|
||||
expect(state.utterances[0].id).toBe('u6')
|
||||
expect(state.utterances[199].id).toBe('u205')
|
||||
})
|
||||
|
||||
it('applies connection/mode/language/passive/speaking setters', () => {
|
||||
let state = seanceReducer(initialSeanceState, { type: 'connection', state: 'open' })
|
||||
expect(state.connection).toBe('open')
|
||||
state = seanceReducer(state, { type: 'set_mode', mode: 'ouija' })
|
||||
expect(state.mode).toBe('ouija')
|
||||
state = seanceReducer(state, { type: 'set_language', language: 'es' })
|
||||
expect(state.language).toBe('es')
|
||||
state = seanceReducer(state, { type: 'set_passive', enabled: true })
|
||||
expect(state.passive).toBe(true)
|
||||
state = seanceReducer(state, { type: 'set_speaking', utteranceId: 'u-1' })
|
||||
expect(state.speakingId).toBe('u-1')
|
||||
})
|
||||
|
||||
it('applies mode and status frames, and ignores pong', () => {
|
||||
let state = frame(initialSeanceState, { type: 'mode', mode: 'radio' })
|
||||
expect(state.mode).toBe('radio')
|
||||
state = frame(state, { type: 'status', state: 'gathering' })
|
||||
expect(state.status).toBe('gathering')
|
||||
const afterPong = frame(state, { type: 'pong' })
|
||||
expect(afterPong).toBe(state) // same reference: pong is a no-op
|
||||
})
|
||||
|
||||
it('dismisses toasts by id', () => {
|
||||
let state = frame(initialSeanceState, { type: 'error', code: 'a', message: 'a' })
|
||||
state = frame(state, { type: 'error', code: 'b', message: 'b' })
|
||||
const victim = state.toasts[0].id
|
||||
state = seanceReducer(state, { type: 'dismiss_toast', id: victim })
|
||||
expect(state.toasts).toHaveLength(1)
|
||||
expect(state.toasts[0].code).toBe('b')
|
||||
})
|
||||
|
||||
it('reset returns to the initial state', () => {
|
||||
let state = frame(initialSeanceState, { type: 'session', id: 's-1' })
|
||||
state = frame(state, { type: 'entity', is_new: true, entity })
|
||||
state = frame(state, { type: 'error', code: 'x', message: 'x' })
|
||||
state = seanceReducer(state, { type: 'local_question', text: 'hi', at: AT })
|
||||
expect(state).not.toEqual(initialSeanceState)
|
||||
|
||||
state = seanceReducer(state, { type: 'reset' })
|
||||
expect(state).toEqual(initialSeanceState)
|
||||
})
|
||||
})
|
||||
466
frontend/src/state/seance.tsx
Normal file
466
frontend/src/state/seance.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
// Séance state: React context + reducer holding the whole session model,
|
||||
// plus a provider that wires the VeilSocket and the spirit-audio queue to it.
|
||||
// The reducer is pure and unit-tested; side effects live in the provider.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { VeilSocket } from '../lib/ws'
|
||||
import type { VeilConnectionState } from '../lib/ws'
|
||||
import { SpiritAudioPlayer } from '../lib/audio'
|
||||
import type {
|
||||
Language,
|
||||
Mode,
|
||||
ServerFrame,
|
||||
SessionStatus,
|
||||
SpiritEntity,
|
||||
Telemetry,
|
||||
UtteranceKind,
|
||||
} from '../lib/types'
|
||||
|
||||
export type Utterance = {
|
||||
id: string
|
||||
kind: UtteranceKind
|
||||
text: string
|
||||
entity: string | null
|
||||
audioUrl: string | null
|
||||
at: number
|
||||
}
|
||||
|
||||
export type AnomalyEntry = {
|
||||
id: number
|
||||
source: 'radio' | 'evp' | 'wire'
|
||||
frequency: number
|
||||
magnitude: number
|
||||
at: number
|
||||
}
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { id: number; kind: 'anomaly'; source: string; frequency: number; magnitude: number; at: number }
|
||||
| { id: number; kind: 'utterance'; utteranceId: string; speaker: string | null; utKind: UtteranceKind; text: string; audioUrl: string | null; at: number }
|
||||
| { id: number; kind: 'question'; text: string; at: number }
|
||||
| { id: number; kind: 'system'; text: string; at: number }
|
||||
|
||||
export type Toast = { id: number; code: string; message: string; at: number }
|
||||
|
||||
export type SeanceState = {
|
||||
connection: VeilConnectionState
|
||||
sessionId: string | null
|
||||
mode: Mode
|
||||
passive: boolean
|
||||
language: Language
|
||||
status: SessionStatus | null
|
||||
entity: SpiritEntity | null
|
||||
entityIsNew: boolean
|
||||
anomalyCount: number
|
||||
utterances: Utterance[]
|
||||
anomalies: AnomalyEntry[]
|
||||
transcript: TranscriptEntry[]
|
||||
replyStreaming: { active: boolean; text: string }
|
||||
telemetry: Telemetry | null
|
||||
toasts: Toast[]
|
||||
speakingId: string | null
|
||||
}
|
||||
|
||||
export const initialSeanceState: SeanceState = {
|
||||
connection: 'closed',
|
||||
sessionId: null,
|
||||
mode: 'wire',
|
||||
passive: false,
|
||||
language: 'en',
|
||||
status: null,
|
||||
entity: null,
|
||||
entityIsNew: false,
|
||||
anomalyCount: 0,
|
||||
utterances: [],
|
||||
anomalies: [],
|
||||
transcript: [],
|
||||
replyStreaming: { active: false, text: '' },
|
||||
telemetry: null,
|
||||
toasts: [],
|
||||
speakingId: null,
|
||||
}
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
let idSeq = 1
|
||||
const nextId = () => idSeq++
|
||||
|
||||
const TRANSCRIPT_CAP = 400
|
||||
const UTTERANCE_CAP = 200
|
||||
const ANOMALY_CAP = 200
|
||||
const TOAST_CAP = 4
|
||||
|
||||
export type SeanceAction =
|
||||
| { type: 'connection'; state: VeilConnectionState }
|
||||
| { type: 'frame'; frame: ServerFrame; at?: number }
|
||||
| { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number; at?: number }
|
||||
| { type: 'local_question'; text: string; at?: number }
|
||||
| { type: 'set_mode'; mode: Mode }
|
||||
| { type: 'set_language'; language: Language }
|
||||
| { type: 'set_passive'; enabled: boolean }
|
||||
| { type: 'set_speaking'; utteranceId: string | null }
|
||||
| { type: 'dismiss_toast'; id: number }
|
||||
| { type: 'reset' }
|
||||
|
||||
function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
|
||||
const out = arr.length >= cap ? arr.slice(arr.length - cap + 1) : [...arr]
|
||||
out.push(item)
|
||||
return out
|
||||
}
|
||||
|
||||
function withToast(state: SeanceState, toast: Toast): SeanceState {
|
||||
const toasts = state.toasts.length >= TOAST_CAP ? state.toasts.slice(1) : [...state.toasts]
|
||||
toasts.push(toast)
|
||||
return { ...state, toasts }
|
||||
}
|
||||
|
||||
export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceState {
|
||||
const at = ('at' in action && action.at) || Date.now()
|
||||
|
||||
switch (action.type) {
|
||||
case 'connection':
|
||||
return { ...state, connection: action.state }
|
||||
|
||||
case 'local_anomaly': {
|
||||
const entry: AnomalyEntry = {
|
||||
id: nextId(),
|
||||
source: action.source,
|
||||
frequency: action.frequency,
|
||||
magnitude: action.magnitude,
|
||||
at,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
anomalies: pushCapped(state.anomalies, entry, ANOMALY_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: entry.id,
|
||||
kind: 'anomaly',
|
||||
source: action.source,
|
||||
frequency: action.frequency,
|
||||
magnitude: action.magnitude,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'local_question':
|
||||
return {
|
||||
...state,
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{ id: nextId(), kind: 'question', text: action.text, at },
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
|
||||
case 'set_mode':
|
||||
return { ...state, mode: action.mode }
|
||||
|
||||
case 'set_language':
|
||||
return { ...state, language: action.language }
|
||||
|
||||
case 'set_passive':
|
||||
return { ...state, passive: action.enabled }
|
||||
|
||||
case 'set_speaking':
|
||||
return { ...state, speakingId: action.utteranceId }
|
||||
|
||||
case 'dismiss_toast':
|
||||
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.id) }
|
||||
|
||||
case 'reset':
|
||||
return { ...initialSeanceState }
|
||||
|
||||
case 'frame': {
|
||||
const frame = action.frame
|
||||
switch (frame.type) {
|
||||
case 'session':
|
||||
return { ...state, sessionId: frame.id }
|
||||
|
||||
case 'pong':
|
||||
return state
|
||||
|
||||
case 'mode':
|
||||
return { ...state, mode: frame.mode }
|
||||
|
||||
case 'status':
|
||||
return { ...state, status: frame.state }
|
||||
|
||||
case 'entity': {
|
||||
return {
|
||||
...state,
|
||||
entity: frame.entity,
|
||||
entityIsNew: frame.is_new,
|
||||
status: null,
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: nextId(),
|
||||
kind: 'system',
|
||||
text: `⟁ ${frame.entity.name} — ${frame.entity.epithet}`,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'anomaly_ack':
|
||||
return { ...state, anomalyCount: frame.count }
|
||||
|
||||
case 'utterance': {
|
||||
const ut: Utterance = {
|
||||
id: frame.id,
|
||||
kind: frame.kind,
|
||||
text: frame.text,
|
||||
entity: frame.entity,
|
||||
audioUrl: null,
|
||||
at,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: null,
|
||||
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: nextId(),
|
||||
kind: 'utterance',
|
||||
utteranceId: ut.id,
|
||||
speaker: ut.entity,
|
||||
utKind: ut.kind,
|
||||
text: ut.text,
|
||||
audioUrl: null,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'audio': {
|
||||
// Pair the audio URL with its utterance (both the store and the
|
||||
// transcript row gain the replay button).
|
||||
return {
|
||||
...state,
|
||||
utterances: state.utterances.map((u) =>
|
||||
u.id === frame.id ? { ...u, audioUrl: frame.url } : u,
|
||||
),
|
||||
transcript: state.transcript.map((t) =>
|
||||
t.kind === 'utterance' && t.utteranceId === frame.id
|
||||
? { ...t, audioUrl: frame.url }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'reply_start':
|
||||
return { ...state, replyStreaming: { active: true, text: '' } }
|
||||
|
||||
case 'reply_token':
|
||||
return {
|
||||
...state,
|
||||
replyStreaming: {
|
||||
active: true,
|
||||
text: state.replyStreaming.text + frame.token,
|
||||
},
|
||||
}
|
||||
|
||||
case 'reply_end': {
|
||||
const ut: Utterance = {
|
||||
id: frame.id,
|
||||
kind: 'reply',
|
||||
text: frame.text,
|
||||
entity: state.entity?.name ?? null,
|
||||
audioUrl: null,
|
||||
at,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: null,
|
||||
replyStreaming: { active: false, text: '' },
|
||||
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: nextId(),
|
||||
kind: 'utterance',
|
||||
utteranceId: ut.id,
|
||||
speaker: ut.entity,
|
||||
utKind: 'reply',
|
||||
text: ut.text,
|
||||
audioUrl: null,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'telemetry':
|
||||
return {
|
||||
...state,
|
||||
telemetry: {
|
||||
jitter_bytes_per_s: frame.jitter_bytes_per_s,
|
||||
latency_variance_ms: frame.latency_variance_ms,
|
||||
latency_mean_ms: frame.latency_mean_ms,
|
||||
dns_ms: frame.dns_ms,
|
||||
},
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return withToast(state, {
|
||||
id: nextId(),
|
||||
code: frame.code,
|
||||
message: frame.message,
|
||||
at,
|
||||
})
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Provider ----
|
||||
|
||||
export type SeanceApi = {
|
||||
state: SeanceState
|
||||
socket: VeilSocket
|
||||
audioPlayer: SpiritAudioPlayer
|
||||
setMode: (mode: Mode) => void
|
||||
setPassive: (enabled: boolean) => void
|
||||
setLanguage: (language: Language) => void
|
||||
summon: () => void
|
||||
ask: (text: string) => void
|
||||
sendAnomaly: (source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => void
|
||||
playUtterance: (utteranceId: string) => void
|
||||
dismissToast: (id: number) => void
|
||||
}
|
||||
|
||||
const SeanceContext = createContext<SeanceApi | null>(null)
|
||||
|
||||
export function SeanceProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(seanceReducer, initialSeanceState)
|
||||
const socketRef = useRef<VeilSocket | null>(null)
|
||||
const playerRef = useRef<SpiritAudioPlayer | null>(null)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
if (!socketRef.current) {
|
||||
socketRef.current = new VeilSocket()
|
||||
playerRef.current = new SpiritAudioPlayer()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const socket = socketRef.current!
|
||||
const player = playerRef.current!
|
||||
|
||||
const offFrame = socket.onFrame((frame) => {
|
||||
dispatch({ type: 'frame', frame, at: Date.now() })
|
||||
if (frame.type === 'audio') {
|
||||
// Auto-play spirit voice on arrival (queued behind anything playing).
|
||||
player.enqueue(frame.id, frame.url)
|
||||
}
|
||||
})
|
||||
const offState = socket.onState((s) => dispatch({ type: 'connection', state: s }))
|
||||
|
||||
player.setCallbacks({
|
||||
onStart: (info) => dispatch({ type: 'set_speaking', utteranceId: info.utteranceId }),
|
||||
onEnd: () => dispatch({ type: 'set_speaking', utteranceId: null }),
|
||||
})
|
||||
|
||||
socket.connect()
|
||||
return () => {
|
||||
offFrame()
|
||||
offState()
|
||||
socket.close()
|
||||
player.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((mode: Mode) => {
|
||||
dispatch({ type: 'set_mode', mode })
|
||||
socketRef.current?.send({ type: 'set_mode', mode })
|
||||
}, [])
|
||||
|
||||
const setPassive = useCallback((enabled: boolean) => {
|
||||
dispatch({ type: 'set_passive', enabled })
|
||||
socketRef.current?.send({ type: 'passive', enabled })
|
||||
}, [])
|
||||
|
||||
const setLanguage = useCallback((language: Language) => {
|
||||
dispatch({ type: 'set_language', language })
|
||||
socketRef.current?.send({ type: 'language', language })
|
||||
}, [])
|
||||
|
||||
const summon = useCallback(() => {
|
||||
socketRef.current?.send({ type: 'summon' })
|
||||
}, [])
|
||||
|
||||
const ask = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
dispatch({ type: 'local_question', text: trimmed, at: Date.now() })
|
||||
socketRef.current?.send({ type: 'question', text: trimmed })
|
||||
}, [])
|
||||
|
||||
const sendAnomaly = useCallback(
|
||||
(source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => {
|
||||
dispatch({ type: 'local_anomaly', source, frequency, magnitude, at: Date.now() })
|
||||
socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const playUtterance = useCallback((utteranceId: string) => {
|
||||
// Replay: re-queue through the same single-queue player.
|
||||
const ut = stateRef.current.utterances.find((u) => u.id === utteranceId)
|
||||
if (ut?.audioUrl) playerRef.current?.enqueue(ut.id, ut.audioUrl)
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
dispatch({ type: 'dismiss_toast', id })
|
||||
}, [])
|
||||
|
||||
const api = useMemo<SeanceApi>(
|
||||
() => ({
|
||||
state,
|
||||
socket: socketRef.current!,
|
||||
audioPlayer: playerRef.current!,
|
||||
setMode,
|
||||
setPassive,
|
||||
setLanguage,
|
||||
summon,
|
||||
ask,
|
||||
sendAnomaly,
|
||||
playUtterance,
|
||||
dismissToast,
|
||||
}),
|
||||
[state, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast],
|
||||
)
|
||||
|
||||
return <SeanceContext.Provider value={api}>{children}</SeanceContext.Provider>
|
||||
}
|
||||
|
||||
export function useSeance(): SeanceApi {
|
||||
const ctx = useContext(SeanceContext)
|
||||
if (!ctx) throw new Error('useSeance must be used inside <SeanceProvider>')
|
||||
return ctx
|
||||
}
|
||||
49
frontend/src/three/GhostCanvas.tsx
Normal file
49
frontend/src/three/GhostCanvas.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { GhostScene } from './GhostScene'
|
||||
import type { GhostMood, GhostVisualState } from './GhostScene'
|
||||
|
||||
export type GhostCanvasProps = {
|
||||
visual: GhostVisualState | null
|
||||
mood: GhostMood
|
||||
/** Called each frame to get the speaking amplitude (0..1). */
|
||||
getAmplitude?: () => number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** React wrapper: owns the canvas element and the GhostScene lifecycle. */
|
||||
export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const sceneRef = useRef<GhostScene | null>(null)
|
||||
const ampRef = useRef(getAmplitude)
|
||||
ampRef.current = getAmplitude
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const scene = new GhostScene(canvas)
|
||||
sceneRef.current = scene
|
||||
scene.start()
|
||||
const ampTimer = window.setInterval(() => {
|
||||
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
|
||||
}, 50)
|
||||
return () => {
|
||||
window.clearInterval(ampTimer)
|
||||
scene.dispose()
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
sceneRef.current?.setVisual(visual)
|
||||
}, [visual])
|
||||
|
||||
useEffect(() => {
|
||||
sceneRef.current?.setMood(mood)
|
||||
}, [mood])
|
||||
|
||||
return (
|
||||
<div className={className} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
477
frontend/src/three/GhostScene.ts
Normal file
477
frontend/src/three/GhostScene.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
// The 3D ghost scene: a smoky, wavy, translucent spirit rendered with
|
||||
// custom shaders — fbm value-noise vertex displacement on a lathed figure,
|
||||
// additive particle wisps, ground mist, fog, slow camera drift.
|
||||
// Reacts to seance state via uniforms (energy / speaking amplitude / hue / form).
|
||||
|
||||
import * as THREE from 'three'
|
||||
import type { GhostForm } from '../lib/types'
|
||||
|
||||
export type GhostMood = 'idle' | 'attuning' | 'gathering' | 'speaking'
|
||||
|
||||
export type GhostVisualState = {
|
||||
hue: number // 0..360
|
||||
form: GhostForm
|
||||
}
|
||||
|
||||
const NOISE_GLSL = /* glsl */ `
|
||||
float hash(vec3 p) {
|
||||
p = fract(p * 0.3183099 + 0.1);
|
||||
p *= 17.0;
|
||||
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
|
||||
}
|
||||
float vnoise(vec3 x) {
|
||||
vec3 i = floor(x);
|
||||
vec3 f = fract(x);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
|
||||
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
|
||||
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
|
||||
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
|
||||
f.z);
|
||||
}
|
||||
float fbm(vec3 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
v += a * vnoise(p);
|
||||
p *= 2.03;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
`
|
||||
|
||||
const GHOST_VERT = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uEnergy; // 0..1 swirl intensity
|
||||
uniform float uSpeak; // 0..1 voice amplitude pulse
|
||||
uniform float uFormStretch; // form-driven vertical stretch
|
||||
varying vec2 vUv;
|
||||
varying float vNoise;
|
||||
varying vec3 vPos;
|
||||
${NOISE_GLSL}
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec3 p = position;
|
||||
p.y *= uFormStretch;
|
||||
|
||||
float swirl = uTime * (0.35 + uEnergy * 1.6);
|
||||
float n = fbm(vec3(p.x * 1.4 + swirl, p.y * 0.9 - uTime * (0.5 + uEnergy), p.z * 1.4));
|
||||
float n2 = fbm(vec3(p.z * 2.2 - swirl * 0.7, p.y * 1.3 + uTime * 0.4, p.x * 2.2));
|
||||
|
||||
// Radial displacement, stronger toward the base (smoke trail) and with energy.
|
||||
float base = smoothstep(1.0, -1.0, p.y);
|
||||
float amp = (0.12 + uEnergy * 0.35 + uSpeak * 0.25) * (0.4 + base);
|
||||
p.x += (n - 0.5) * 2.0 * amp;
|
||||
p.z += (n2 - 0.5) * 2.0 * amp;
|
||||
|
||||
// Breathing pulse while speaking.
|
||||
float pulse = 1.0 + uSpeak * 0.18 * sin(uTime * 9.0 + p.y * 3.0);
|
||||
p.xz *= pulse;
|
||||
|
||||
vNoise = n;
|
||||
vPos = p;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const GHOST_FRAG = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uTime;
|
||||
uniform float uEnergy;
|
||||
uniform float uSpeak;
|
||||
uniform float uOpacity;
|
||||
uniform float uDark; // shade form absorbs light
|
||||
varying vec2 vUv;
|
||||
varying float vNoise;
|
||||
varying vec3 vPos;
|
||||
void main() {
|
||||
// Fade top and bottom, fade with noise, brighter core.
|
||||
float vert = smoothstep(0.0, 0.25, vUv.y) * smoothstep(1.0, 0.55, vUv.y);
|
||||
float body = smoothstep(0.25, 0.75, vNoise + uSpeak * 0.2);
|
||||
float a = vert * body * (0.35 + uEnergy * 0.5 + uSpeak * 0.35) * uOpacity;
|
||||
vec3 col = uColor * (0.55 + uEnergy * 0.8 + uSpeak * 0.9);
|
||||
col = mix(col, col * 0.12, uDark);
|
||||
// Flicker.
|
||||
col *= 0.92 + 0.08 * sin(uTime * 13.7 + vPos.y * 5.0);
|
||||
gl_FragColor = vec4(col, a);
|
||||
}
|
||||
`
|
||||
|
||||
const WISP_VERT = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uEnergy;
|
||||
uniform float uSpeak;
|
||||
uniform float uMoteSpeed;
|
||||
attribute float aSeed;
|
||||
varying float vSeed;
|
||||
void main() {
|
||||
vSeed = aSeed;
|
||||
vec3 p = position;
|
||||
float t = uTime * (0.12 + 0.5 * uEnergy) * uMoteSpeed + aSeed * 17.0;
|
||||
float r = length(p.xz) * (1.0 + 0.2 * sin(t * 0.9 + aSeed * 6.28));
|
||||
float ang = atan(p.z, p.x) + t * (0.3 + aSeed * 0.2);
|
||||
p.x = cos(ang) * r;
|
||||
p.z = sin(ang) * r;
|
||||
p.y += sin(t + aSeed * 12.0) * (0.35 + uSpeak * 0.4);
|
||||
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||||
gl_PointSize = (2.0 + aSeed * 5.0 + uSpeak * 3.0) * (18.0 / -mv.z);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`
|
||||
|
||||
const WISP_FRAG = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying float vSeed;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord - 0.5;
|
||||
float d = length(c);
|
||||
float a = smoothstep(0.5, 0.0, d);
|
||||
gl_FragColor = vec4(uColor * (0.7 + vSeed * 0.6), a * uOpacity);
|
||||
}
|
||||
`
|
||||
|
||||
const MIST_VERT = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const MIST_FRAG = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vUv;
|
||||
${NOISE_GLSL}
|
||||
void main() {
|
||||
vec2 c = vUv - 0.5;
|
||||
float r = length(c) * 2.0;
|
||||
float n = fbm(vec3(vUv * 4.0, uTime * 0.08));
|
||||
float a = smoothstep(1.0, 0.1, r) * n * uOpacity;
|
||||
gl_FragColor = vec4(uColor * 0.6, a);
|
||||
}
|
||||
`
|
||||
|
||||
function hsl(hue: number, s: number, l: number): THREE.Color {
|
||||
return new THREE.Color().setHSL(((hue % 360) + 360) % 360 / 360, s, l)
|
||||
}
|
||||
|
||||
/** Build the lathed "hooded wisp" figure profile. */
|
||||
function buildFigureGeometry(): THREE.LatheGeometry {
|
||||
const pts: THREE.Vector2[] = []
|
||||
// Profile: wide smoky base → waist → shoulders → head → dissolving tip.
|
||||
const profile: Array<[number, number]> = [
|
||||
[0.0, -1.6],
|
||||
[0.9, -1.55],
|
||||
[0.75, -1.1],
|
||||
[0.5, -0.55],
|
||||
[0.42, -0.1],
|
||||
[0.5, 0.25],
|
||||
[0.46, 0.55],
|
||||
[0.3, 0.8],
|
||||
[0.34, 0.95], // hood
|
||||
[0.22, 1.15],
|
||||
[0.08, 1.35],
|
||||
[0.0, 1.55],
|
||||
]
|
||||
for (const [x, y] of profile) pts.push(new THREE.Vector2(x, y))
|
||||
const geo = new THREE.LatheGeometry(pts, 48)
|
||||
geo.computeVertexNormals()
|
||||
return geo
|
||||
}
|
||||
|
||||
type FormParams = {
|
||||
scale: number
|
||||
stretch: number
|
||||
moteCount: number
|
||||
moteSpeed: number
|
||||
dark: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
function formParams(form: GhostForm): FormParams {
|
||||
switch (form) {
|
||||
case 'fairy':
|
||||
return { scale: 0.55, stretch: 0.9, moteCount: 260, moteSpeed: 2.2, dark: 0, opacity: 1 }
|
||||
case 'banshee':
|
||||
return { scale: 1.05, stretch: 1.55, moteCount: 140, moteSpeed: 0.8, dark: 0.15, opacity: 0.95 }
|
||||
case 'shade':
|
||||
return { scale: 1.1, stretch: 1.15, moteCount: 90, moteSpeed: 0.5, dark: 0.85, opacity: 0.9 }
|
||||
case 'wisp':
|
||||
default:
|
||||
return { scale: 0.85, stretch: 1.0, moteCount: 160, moteSpeed: 1.2, dark: 0, opacity: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
export class GhostScene {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private scene = new THREE.Scene()
|
||||
private camera: THREE.PerspectiveCamera
|
||||
private canvas: HTMLCanvasElement
|
||||
|
||||
private ghostMat: THREE.ShaderMaterial
|
||||
private ghost: THREE.Mesh
|
||||
private veil: THREE.Mesh // banshee trailing veil
|
||||
private wispMat: THREE.ShaderMaterial
|
||||
private wisps: THREE.Points
|
||||
private mistMat: THREE.ShaderMaterial
|
||||
private mists: THREE.Mesh[] = []
|
||||
private farWisps: THREE.Points
|
||||
private farWispMat: THREE.PointsMaterial
|
||||
|
||||
private raf = 0
|
||||
private running = false
|
||||
private clock = new THREE.Clock()
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
private mood: GhostMood = 'idle'
|
||||
private visual: GhostVisualState | null = null
|
||||
private speakAmp = 0
|
||||
private smoothedAmp = 0
|
||||
private energy = 0.15
|
||||
private disposed = false
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
})
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 60)
|
||||
this.camera.position.set(0, 0.6, 6.2)
|
||||
this.scene.fog = new THREE.FogExp2(0x07070d, 0.09)
|
||||
|
||||
// --- Ghost figure ---
|
||||
this.ghostMat = new THREE.ShaderMaterial({
|
||||
vertexShader: GHOST_VERT,
|
||||
fragmentShader: GHOST_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.DoubleSide,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uEnergy: { value: 0.15 },
|
||||
uSpeak: { value: 0 },
|
||||
uFormStretch: { value: 1 },
|
||||
uColor: { value: hsl(150, 0.9, 0.65) },
|
||||
uOpacity: { value: 1 },
|
||||
uDark: { value: 0 },
|
||||
},
|
||||
})
|
||||
this.ghost = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
|
||||
this.ghost.position.set(0, 0.4, 0)
|
||||
this.ghost.visible = false
|
||||
this.scene.add(this.ghost)
|
||||
|
||||
// Trailing veil (banshee): a second, elongated, fainter copy behind.
|
||||
this.veil = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
|
||||
this.veil.position.set(0.35, 0.1, -0.6)
|
||||
this.veil.scale.set(0.8, 1.3, 0.8)
|
||||
this.veil.visible = false
|
||||
this.scene.add(this.veil)
|
||||
|
||||
// --- Orbiting wisps / motes ---
|
||||
this.wispMat = new THREE.ShaderMaterial({
|
||||
vertexShader: WISP_VERT,
|
||||
fragmentShader: WISP_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uEnergy: { value: 0.15 },
|
||||
uSpeak: { value: 0 },
|
||||
uMoteSpeed: { value: 1.2 },
|
||||
uColor: { value: hsl(150, 0.9, 0.7) },
|
||||
uOpacity: { value: 0.55 },
|
||||
},
|
||||
})
|
||||
this.wisps = new THREE.Points(this.buildWispGeometry(260), this.wispMat)
|
||||
this.wisps.position.set(0, 0.4, 0)
|
||||
this.wisps.visible = false
|
||||
this.scene.add(this.wisps)
|
||||
|
||||
// --- Ground mist planes ---
|
||||
this.mistMat = new THREE.ShaderMaterial({
|
||||
vertexShader: MIST_VERT,
|
||||
fragmentShader: MIST_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: hsl(160, 0.5, 0.4) },
|
||||
uOpacity: { value: 0.16 },
|
||||
},
|
||||
})
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), this.mistMat)
|
||||
m.rotation.x = -Math.PI / 2
|
||||
m.position.set(0, -1.5 + i * 0.22, 0)
|
||||
m.rotation.z = (i * Math.PI) / 3
|
||||
this.scene.add(m)
|
||||
this.mists.push(m)
|
||||
}
|
||||
|
||||
// --- Far-off ambient wisps (no entity summoned) ---
|
||||
const farGeo = new THREE.BufferGeometry()
|
||||
const farCount = 60
|
||||
const farPos = new Float32Array(farCount * 3)
|
||||
for (let i = 0; i < farCount; i++) {
|
||||
farPos[i * 3] = (Math.random() - 0.5) * 30
|
||||
farPos[i * 3 + 1] = Math.random() * 6 - 1
|
||||
farPos[i * 3 + 2] = -6 - Math.random() * 18
|
||||
}
|
||||
farGeo.setAttribute('position', new THREE.BufferAttribute(farPos, 3))
|
||||
this.farWispMat = new THREE.PointsMaterial({
|
||||
color: 0x7cffb2,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
})
|
||||
this.farWisps = new THREE.Points(farGeo, this.farWispMat)
|
||||
this.scene.add(this.farWisps)
|
||||
|
||||
this.handleResize()
|
||||
this.resizeObserver = new ResizeObserver(() => this.handleResize())
|
||||
if (canvas.parentElement) this.resizeObserver.observe(canvas.parentElement)
|
||||
}
|
||||
|
||||
private buildWispGeometry(count: number): THREE.BufferGeometry {
|
||||
const geo = new THREE.BufferGeometry()
|
||||
const pos = new Float32Array(count * 3)
|
||||
const seed = new Float32Array(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = 0.5 + Math.random() * 1.6
|
||||
const a = Math.random() * Math.PI * 2
|
||||
pos[i * 3] = Math.cos(a) * r
|
||||
pos[i * 3 + 1] = Math.random() * 3 - 1.4
|
||||
pos[i * 3 + 2] = Math.sin(a) * r
|
||||
seed[i] = Math.random()
|
||||
}
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
||||
geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1))
|
||||
return geo
|
||||
}
|
||||
|
||||
setMood(mood: GhostMood): void {
|
||||
this.mood = mood
|
||||
}
|
||||
|
||||
/** Call every frame with the audio player's instantaneous amplitude. */
|
||||
setSpeakAmplitude(amp: number): void {
|
||||
this.speakAmp = Math.max(0, Math.min(1, amp))
|
||||
}
|
||||
|
||||
setVisual(visual: GhostVisualState | null): void {
|
||||
this.visual = visual
|
||||
if (!visual) {
|
||||
this.ghost.visible = false
|
||||
this.veil.visible = false
|
||||
this.wisps.visible = false
|
||||
return
|
||||
}
|
||||
const params = formParams(visual.form)
|
||||
const color = hsl(visual.hue, 0.85, 0.62)
|
||||
const ghostU = this.ghostMat.uniforms
|
||||
ghostU.uColor.value = color
|
||||
ghostU.uFormStretch.value = params.stretch
|
||||
ghostU.uDark.value = params.dark
|
||||
ghostU.uOpacity.value = params.opacity
|
||||
this.wispMat.uniforms.uColor.value = hsl(visual.hue, 0.9, 0.7)
|
||||
this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed
|
||||
this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4)
|
||||
this.ghost.scale.setScalar(params.scale)
|
||||
this.ghost.visible = true
|
||||
this.wisps.visible = true
|
||||
this.veil.visible = visual.form === 'banshee'
|
||||
// Rebuild motes if the form wants a different count.
|
||||
const current = this.wisps.geometry.getAttribute('position')?.count ?? 0
|
||||
if (current !== params.moteCount) {
|
||||
this.wisps.geometry.dispose()
|
||||
this.wisps.geometry = this.buildWispGeometry(params.moteCount)
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running || this.disposed) return
|
||||
this.running = true
|
||||
this.clock.start()
|
||||
const loop = () => {
|
||||
if (!this.running) return
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
this.renderFrame()
|
||||
}
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
private renderFrame(): void {
|
||||
const t = this.clock.getElapsedTime()
|
||||
|
||||
// Energy target from mood; smooth toward it.
|
||||
const target =
|
||||
this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15
|
||||
this.energy += (target - this.energy) * 0.04
|
||||
this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25
|
||||
|
||||
const ghostU = this.ghostMat.uniforms
|
||||
ghostU.uTime.value = t
|
||||
ghostU.uEnergy.value = this.energy
|
||||
ghostU.uSpeak.value = this.smoothedAmp
|
||||
this.wispMat.uniforms.uTime.value = t
|
||||
this.wispMat.uniforms.uEnergy.value = this.energy
|
||||
this.wispMat.uniforms.uSpeak.value = this.smoothedAmp
|
||||
this.mistMat.uniforms.uTime.value = t
|
||||
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1
|
||||
|
||||
// Idle drift / slow camera wander.
|
||||
this.ghost.position.y = 0.4 + Math.sin(t * 0.6) * 0.12
|
||||
this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3
|
||||
this.veil.position.y = this.ghost.position.y - 0.3
|
||||
this.camera.position.x = Math.sin(t * 0.11) * 0.55
|
||||
this.camera.position.y = 0.6 + Math.sin(t * 0.07) * 0.25
|
||||
this.camera.lookAt(0, 0.4, 0)
|
||||
|
||||
// Far wisps drift slowly.
|
||||
this.farWisps.rotation.y = t * 0.01
|
||||
this.farWispMat.opacity = this.visual ? 0.12 : 0.3 + 0.1 * Math.sin(t * 0.5)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
const parent = this.canvas.parentElement
|
||||
const w = parent?.clientWidth || window.innerWidth
|
||||
const h = parent?.clientHeight || window.innerHeight
|
||||
if (w === 0 || h === 0) return
|
||||
this.renderer.setSize(w, h, false)
|
||||
this.camera.aspect = w / h
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.running = false
|
||||
cancelAnimationFrame(this.raf)
|
||||
this.resizeObserver?.disconnect()
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points) {
|
||||
obj.geometry.dispose()
|
||||
const mat = obj.material
|
||||
if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
|
||||
else mat.dispose()
|
||||
}
|
||||
})
|
||||
this.renderer.dispose()
|
||||
}
|
||||
}
|
||||
77
frontend/src/types/webusb.d.ts
vendored
Normal file
77
frontend/src/types/webusb.d.ts
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
// Minimal WebUSB ambient declarations — just the surface src/lib/sdr.ts uses.
|
||||
// WebUSB is not part of TS's dom lib and @types/web doesn't carry it.
|
||||
|
||||
interface USBEndpoint {
|
||||
endpointNumber: number
|
||||
direction: 'in' | 'out'
|
||||
type: 'bulk' | 'interrupt' | 'isochronous'
|
||||
packetSize: number
|
||||
}
|
||||
|
||||
interface USBAlternateInterface {
|
||||
alternateSetting: number
|
||||
interfaceClass: number
|
||||
interfaceSubclass: number
|
||||
interfaceProtocol: number
|
||||
interfaceName?: string
|
||||
endpoints: USBEndpoint[]
|
||||
}
|
||||
|
||||
interface USBInterface {
|
||||
interfaceNumber: number
|
||||
alternates: USBAlternateInterface[]
|
||||
claimed: boolean
|
||||
}
|
||||
|
||||
interface USBConfiguration {
|
||||
configurationValue: number
|
||||
configurationName?: string
|
||||
interfaces: USBInterface[]
|
||||
}
|
||||
|
||||
interface USBDevice {
|
||||
readonly opened: boolean
|
||||
readonly configuration: USBConfiguration | null
|
||||
open(): Promise<void>
|
||||
close(): Promise<void>
|
||||
claimInterface(interfaceNumber: number): Promise<void>
|
||||
releaseInterface(interfaceNumber: number): Promise<void>
|
||||
selectAlternateInterface(
|
||||
interfaceNumber: number,
|
||||
alternateSetting: number,
|
||||
): Promise<void>
|
||||
controlTransferOut(
|
||||
setup: {
|
||||
requestType: 'standard' | 'class' | 'vendor'
|
||||
recipient: 'device' | 'interface' | 'endpoint' | 'other'
|
||||
request: number
|
||||
value: number
|
||||
index: number
|
||||
},
|
||||
data?: BufferSource,
|
||||
): Promise<USBOutTransferResult>
|
||||
transferIn(
|
||||
endpointNumber: number,
|
||||
length: number,
|
||||
): Promise<USBInTransferResult>
|
||||
}
|
||||
|
||||
interface USBInTransferResult {
|
||||
data?: DataView
|
||||
status: 'ok' | 'stall' | 'babble'
|
||||
}
|
||||
|
||||
interface USBOutTransferResult {
|
||||
bytesWritten: number
|
||||
status: 'ok' | 'stall'
|
||||
}
|
||||
|
||||
interface USB {
|
||||
requestDevice(options: {
|
||||
filters: Array<{ vendorId?: number; productId?: number }>
|
||||
}): Promise<USBDevice>
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
readonly usb: USB
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/auth': 'http://localhost:7777',
|
||||
'/healthz': 'http://localhost:7777',
|
||||
'/api': 'http://localhost:7777',
|
||||
'/audio': 'http://localhost:7777',
|
||||
'/ws': {
|
||||
target: 'http://localhost:7777',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user