mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-07 10:45:58 -10:00
feat(wave14): modal resilience & z-index ladder, rustic rollout, first-class concentration
- Shared ModalShell + useFocusTrap: ESC dismiss, focus trap/restore, ARIA dialog semantics on 15 sheets; ShortcutsModal nests above other modals - Z-index ladder (--z-* tokens): fixed canvas-banner/modal, navbar-dropdown/ backdrop and FX/popover collisions; no raw z-50 remains - Rustic Tavern & Parchment rollout: Navbar, InitiativeTracker, MacroQuickbar, both audio modals; glass/card surfaces re-tokenized; fonts preconnected - Audio: tanh spatial pan curve, attack ramps kill onset clicks, SFX debounce invariant documented - vtt-core: ConcentrationState + begin/end/apply_damage_to_concentration, RulesEvaluator::edge_from_conditions; +3 SRD integration tests; serde default keeps legacy sessions loadable - Chore: drop unused pixi.js/three deps + dead render modules; preserve scratch transcript under docs/ Gates: cargo ✅ · pytest 57/1 ✅ · MCR 100% / HCI 1.0 / AFPR 0% ✅ · build ✅ Co-Authored-By: Claude <[email protected]>
This commit is contained in:
1 parent
013677eb4d
commit
ff4d47805d
37 files changed
+5496
-1438
No files matched your search
@@ -9,6 +9,18 @@
|
||||
content="An AI-native virtual tabletop for D&D 5e — deterministic Rust rules engine, AI Game Master, real-time multiplayer maps, positional voice and procedural dungeons."
|
||||
/>
|
||||
<title>AI-Native Virtual Tabletop (VTT)</title>
|
||||
<!-- Rustic identity fonts — both SIL Open Font License 1.1 (see
|
||||
compendium/ATTRIBUTION.md): Cinzel for engraved display headers,
|
||||
EB Garamond for in-world serif prose. Loaded via preconnect +
|
||||
stylesheet here instead of a CSS @import so the browser can start
|
||||
the font fetch in parallel with the JS/CSS bundle instead of
|
||||
serializing behind it. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400..700;1,400..600&display=swap"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b5cf6' stroke-width='2'><polygon points='12 2 2 7 12 12 22 7 12 2'/><polyline points='2 17 12 22 22 17'/><polyline points='2 12 12 17 22 12'/></svg>" />
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 antialiased overflow-hidden select-none">
|
||||
|
||||
Generated
-159
@@ -10,11 +10,9 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"lucide-react": "^0.363.0",
|
||||
"pixi.js": "^8.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"three": "^0.163.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-websocket": "^2.0.4",
|
||||
"yjs": "^13.6.14"
|
||||
@@ -23,7 +21,6 @@
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.163.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0"
|
||||
@@ -743,12 +740,6 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@pixi/colord": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
|
||||
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1389,13 +1380,6 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1441,12 +1425,6 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/earcut": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1491,34 +1469,6 @@
|
||||
"@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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.163.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.163.0.tgz",
|
||||
"integrity": "sha512-uIdDhsXRpQiBUkflBS/i1l3JX14fW6Ot9csed60nfbZNXHDTRsnV2xnTVwXcgbvTiboAR4IW+t+lTL5f1rqIqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "~23.1.1",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.18.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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -1540,21 +1490,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.72",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
|
||||
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-leveldown": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
||||
@@ -1750,12 +1685,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.412",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
|
||||
@@ -1854,19 +1783,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1891,15 +1807,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gifuct-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-binary-schema-parser": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1941,12 +1848,6 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ismobilejs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
@@ -1966,12 +1867,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-binary-schema-parser": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -2489,13 +2384,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2550,44 +2438,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
|
||||
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pixi.js": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.0.tgz",
|
||||
"integrity": "sha512-WL7+yhxgNnI87AbucMR2X7yXmi/XhhYA25w2wV1QaNv6WMByM02kSX2ryreSq38OavHytFD0zY0bX6Nqgj/WAA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples",
|
||||
"playground"
|
||||
],
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/earcut": "^3.0.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"@xmldom/xmldom": "^0.8.13",
|
||||
"earcut": "^3.0.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"ismobilejs": "^1.1.1",
|
||||
"parse-svg-path": "^0.2.0",
|
||||
"tiny-lru": "^11.4.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/pixijs"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -2796,21 +2652,6 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.163.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.163.0.tgz",
|
||||
"integrity": "sha512-HlMgCb2TF/dTLRtknBnjUTsR8FsDqBY43itYop2+Zg822I+Kd0Ua2vs8CvfBVefXkBdNDrLMoRTGCIIpfCuDew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-lru": {
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
|
||||
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"lucide-react": "^0.363.0",
|
||||
"pixi.js": "^8.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"three": "^0.163.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-websocket": "^2.0.4",
|
||||
"yjs": "^13.6.14"
|
||||
@@ -25,7 +23,6 @@
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.163.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0"
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Radio,
|
||||
Headphones,
|
||||
Sliders,
|
||||
Mic,
|
||||
X,
|
||||
Sparkles,
|
||||
Swords,
|
||||
Flame,
|
||||
Activity,
|
||||
ShieldAlert,
|
||||
Users
|
||||
import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Radio,
|
||||
Headphones,
|
||||
Sliders,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { globalSpatialAudio } from '../render/spatial_audio';
|
||||
import { globalWebRTCMesh, PeerAudioState } from '../render/webrtc_mesh';
|
||||
import { Token } from './TacticalCanvas';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface AudioMixerModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -69,7 +63,7 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
const maxRadius = Math.min(cx, cy) - 16;
|
||||
|
||||
// Background Radar Rings
|
||||
ctx.strokeStyle = 'rgba(168, 85, 247, 0.2)';
|
||||
ctx.strokeStyle = 'rgba(217, 119, 6, 0.25)';
|
||||
ctx.lineWidth = 1;
|
||||
[0.33, 0.66, 1.0].forEach((pct) => {
|
||||
ctx.beginPath();
|
||||
@@ -86,7 +80,7 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
ctx.stroke();
|
||||
|
||||
// Distance labels
|
||||
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
|
||||
ctx.fillStyle = 'rgba(216, 203, 184, 0.65)';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillText('15 ft', cx + maxRadius * 0.33 + 4, cy - 4);
|
||||
ctx.fillText('30 ft', cx + maxRadius * 0.66 + 4, cy - 4);
|
||||
@@ -98,8 +92,8 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
const sweepGrad = ctx.createLinearGradient(0, 0, maxRadius, 0);
|
||||
sweepGrad.addColorStop(0, 'rgba(168, 85, 247, 0.4)');
|
||||
sweepGrad.addColorStop(1, 'rgba(168, 85, 247, 0.0)');
|
||||
sweepGrad.addColorStop(0, 'rgba(217, 119, 6, 0.45)');
|
||||
sweepGrad.addColorStop(1, 'rgba(217, 119, 6, 0.0)');
|
||||
ctx.fillStyle = sweepGrad;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
@@ -108,11 +102,11 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
ctx.restore();
|
||||
|
||||
// Plot Center Listener Token (Selected Token)
|
||||
ctx.fillStyle = '#a855f7';
|
||||
ctx.fillStyle = '#d97706';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillStyle = '#f5ede0';
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.fillText(selectedToken ? selectedToken.name.split(' ')[0] : 'Listener', cx + 8, cy + 3);
|
||||
|
||||
@@ -145,7 +139,7 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
ctx.arc(tx, ty, 8 + Math.sin(Date.now() * 0.005) * 2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.fillStyle = '#d8cbb8';
|
||||
ctx.font = '8px monospace';
|
||||
ctx.fillText(t.name.split(' ')[0], tx + 7, ty + 3);
|
||||
});
|
||||
@@ -157,8 +151,6 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
return () => cancelAnimationFrame(animFrame);
|
||||
}, [isOpen, tokens, selectedToken]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleMasterVolChange = (val: number) => {
|
||||
setMasterVolume(val);
|
||||
globalSpatialAudio.setMasterVolume(val);
|
||||
@@ -171,190 +163,175 @@ export const AudioMixerModal: React.FC<AudioMixerModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-md flex items-center justify-center p-4 select-none animate-fadeIn">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-3xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-purple-950/80 border border-purple-800 flex items-center justify-center text-purple-400">
|
||||
<Headphones className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-sm font-display text-slate-100 flex items-center gap-2">
|
||||
<span>3D Spatial Audio & WebRTC Voice Radar</span>
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-purple-950 text-purple-300 border border-purple-800">
|
||||
Web Audio API
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[11px] text-slate-400 font-mono">
|
||||
Real-time acoustic distance rolloff & azimuth stereo panning based on battlefield coordinates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="3D Spatial Audio & WebRTC Voice Radar"
|
||||
subtitle="Real-time acoustic distance rolloff & azimuth stereo panning based on battlefield coordinates."
|
||||
icon={<Headphones className="w-5 h-5" />}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex items-center justify-between text-[11px] font-mono text-[#f5ede0]/70">
|
||||
<span>
|
||||
Active Acoustic Listener:{' '}
|
||||
<strong className="text-[var(--tavern-accent)]">{selectedToken?.name || 'Party'}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body Layout */}
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left Column: 2D Acoustic Radar Canvas */}
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Radio className="w-4 h-4" />
|
||||
<span>Acoustic Proximity Radar</span>
|
||||
</div>
|
||||
|
||||
<div className="relative p-2 bg-slate-950 rounded-2xl border border-slate-800 shadow-inner">
|
||||
<canvas
|
||||
ref={radarCanvasRef}
|
||||
width={260}
|
||||
height={260}
|
||||
className="rounded-xl block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Spatial Sound Test Triggers */}
|
||||
<div className="w-full space-y-1.5 pt-2">
|
||||
<div className="text-[10px] font-mono text-slate-500 uppercase font-bold text-center">
|
||||
Auditory Position Tests:
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={() => globalSpatialAudio.playSpatialImpact(10, 4)}
|
||||
className="px-2 py-1.5 bg-slate-800 hover:bg-purple-900/40 text-slate-200 rounded-lg text-[10px] font-mono border border-slate-700 transition"
|
||||
>
|
||||
⚔ Strike (Right)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => globalSpatialAudio.playSpatialSpell(4, 8)}
|
||||
className="px-2 py-1.5 bg-slate-800 hover:bg-purple-900/40 text-slate-200 rounded-lg text-[10px] font-mono border border-slate-700 transition"
|
||||
>
|
||||
🔥 Spell (Down)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => globalSpatialAudio.playSpatialCreatureRoar(11, 6)}
|
||||
className="px-2 py-1.5 bg-slate-800 hover:bg-rose-900/40 text-slate-200 rounded-lg text-[10px] font-mono border border-slate-700 transition"
|
||||
>
|
||||
🐉 Roar (Far)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Audio Mixer & Peer Channels */}
|
||||
<div className="space-y-4">
|
||||
{/* Master Controls */}
|
||||
<div className="vtt-glass-panel p-4 rounded-xl border border-slate-800 space-y-3 shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold font-display uppercase tracking-wider text-purple-400 flex items-center gap-1.5">
|
||||
<Sliders className="w-3.5 h-3.5" />
|
||||
<span>Master Acoustics</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleToggleSpatial}
|
||||
className={`text-[10px] font-mono px-2 py-0.5 rounded-lg border transition font-bold ${
|
||||
isSpatialEnabled
|
||||
? 'bg-purple-950 text-purple-300 border-purple-600 ring-1 ring-purple-600'
|
||||
: 'bg-slate-800 text-slate-400 border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isSpatialEnabled ? '3D Stereo Panning: ON' : '3D Spatial: OFF (Flat Mono)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-mono text-slate-400 mb-1">
|
||||
<span>Master Output Gain:</span>
|
||||
<strong className="text-slate-200">{Math.round(masterVolume * 100)}%</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={masterVolume}
|
||||
onChange={(e) => handleMasterVolChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-purple-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peer Channels */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-mono font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>WebRTC Voice Mesh Channels</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto vtt-scrollbar pr-1">
|
||||
{peers.map((peer) => (
|
||||
<div
|
||||
key={peer.peerId}
|
||||
className="p-2.5 bg-slate-950/70 border border-slate-800 rounded-xl flex items-center justify-between text-xs font-mono"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
peer.isSpeaking ? 'bg-emerald-400 animate-pulse' : 'bg-slate-700'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-bold text-slate-200 text-xs">{peer.name}</div>
|
||||
<div className="text-[9px] text-slate-500">
|
||||
Pos: [{peer.x}, {peer.y}] · Vol: {Math.round(peer.volume * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={peer.volume}
|
||||
onChange={(e) =>
|
||||
globalWebRTCMesh.setPeerVolume(peer.peerId, parseFloat(e.target.value))
|
||||
}
|
||||
className="w-16 accent-purple-500 cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
onClick={() => globalWebRTCMesh.togglePeerMute(peer.peerId)}
|
||||
className={`p-1 rounded ${
|
||||
peer.isMuted
|
||||
? 'bg-rose-950 text-rose-300 border border-rose-800'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{peer.isMuted ? <VolumeX className="w-3 h-3" /> : <Volume2 className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-3 border-t border-slate-800 bg-slate-950/60 px-6 flex items-center justify-between text-[11px] font-mono text-slate-400">
|
||||
<span>Active Acoustic Listener: <strong className="text-purple-300">{selectedToken?.name || 'Party'}</strong></span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg font-mono text-xs transition"
|
||||
className="px-4 py-1.5 bg-[var(--tavern-surface)] hover:bg-[var(--rp-amber-600)] border border-[var(--tavern-border)] hover:border-[var(--rp-amber-600)] text-[#f5ede0] rounded-lg font-mono text-xs transition"
|
||||
>
|
||||
Close Mixer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Body Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 select-none">
|
||||
{/* Left Column: 2D Acoustic Radar Canvas */}
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-[var(--tavern-accent)] uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Radio className="w-4 h-4" />
|
||||
<span>Acoustic Proximity Radar</span>
|
||||
</div>
|
||||
|
||||
<div className="relative p-2 bg-[var(--tavern-bg)] rounded-2xl border border-[var(--tavern-border)] shadow-inner">
|
||||
<canvas
|
||||
ref={radarCanvasRef}
|
||||
width={260}
|
||||
height={260}
|
||||
className="rounded-xl block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Spatial Sound Test Triggers */}
|
||||
<div className="w-full space-y-1.5 pt-2">
|
||||
<div className="text-[10px] font-mono text-[#f5ede0]/60 uppercase font-bold text-center">
|
||||
Auditory Position Tests:
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={() => globalSpatialAudio.playSpatialImpact(10, 4)}
|
||||
className="px-2 py-1.5 bg-[var(--tavern-surface)] hover:bg-[var(--rp-amber-600)]/30 text-[#f5ede0] rounded-lg text-[10px] font-mono border border-[var(--tavern-border)] transition"
|
||||
>
|
||||
⚔ Strike (Right)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => globalSpatialAudio.playSpatialSpell(4, 8)}
|
||||
className="px-2 py-1.5 bg-[var(--tavern-surface)] hover:bg-[var(--rp-amber-600)]/30 text-[#f5ede0] rounded-lg text-[10px] font-mono border border-[var(--tavern-border)] transition"
|
||||
>
|
||||
🔥 Spell (Down)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => globalSpatialAudio.playSpatialCreatureRoar(11, 6)}
|
||||
className="px-2 py-1.5 bg-[var(--tavern-surface)] hover:bg-rose-900/40 text-[#f5ede0] rounded-lg text-[10px] font-mono border border-[var(--tavern-border)] transition"
|
||||
>
|
||||
🐉 Roar (Far)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Audio Mixer & Peer Channels */}
|
||||
<div className="space-y-4">
|
||||
{/* Master Controls */}
|
||||
<div className="vtt-surface p-4 rounded-xl space-y-3 shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="vtt-engraved text-xs font-bold uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sliders className="w-3.5 h-3.5" />
|
||||
<span>Master Acoustics</span>
|
||||
</span>
|
||||
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[var(--tavern-bg)] text-[#f5ede0]/80 border border-[var(--tavern-border)]">
|
||||
Web Audio API
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleToggleSpatial}
|
||||
className={`text-[10px] font-mono px-2 py-0.5 rounded-lg border transition font-bold ${
|
||||
isSpatialEnabled
|
||||
? 'bg-[var(--rp-amber-600)]/20 text-amber-300 border-[var(--tavern-accent)] ring-1 ring-[var(--tavern-accent)]'
|
||||
: 'bg-[var(--tavern-bg)] text-[#f5ede0]/70 border-[var(--tavern-border)]'
|
||||
}`}
|
||||
>
|
||||
{isSpatialEnabled ? '3D Stereo Panning: ON' : '3D Spatial: OFF (Flat Mono)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-mono text-[#f5ede0]/70 mb-1">
|
||||
<span>Master Output Gain:</span>
|
||||
<strong className="text-[#f5ede0]">{Math.round(masterVolume * 100)}%</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={masterVolume}
|
||||
onChange={(e) => handleMasterVolChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-[var(--tavern-accent)] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peer Channels */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-mono font-bold text-[#f5ede0]/70 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>WebRTC Voice Mesh Channels</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto vtt-scrollbar pr-1">
|
||||
{peers.map((peer) => (
|
||||
<div
|
||||
key={peer.peerId}
|
||||
className="p-2.5 bg-[var(--tavern-bg)]/70 border border-[var(--tavern-border)] rounded-xl flex items-center justify-between text-xs font-mono"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
peer.isSpeaking ? 'bg-emerald-400 animate-pulse' : 'bg-[var(--tavern-border)]'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-bold text-[#f5ede0] text-xs">{peer.name}</div>
|
||||
<div className="text-[9px] text-[#f5ede0]/60">
|
||||
Pos: [{peer.x}, {peer.y}] · Vol: {Math.round(peer.volume * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={peer.volume}
|
||||
onChange={(e) =>
|
||||
globalWebRTCMesh.setPeerVolume(peer.peerId, parseFloat(e.target.value))
|
||||
}
|
||||
className="w-16 accent-[var(--tavern-accent)] cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
onClick={() => globalWebRTCMesh.togglePeerMute(peer.peerId)}
|
||||
className={`p-1 rounded ${
|
||||
peer.isMuted
|
||||
? 'bg-rose-950 text-rose-300 border border-rose-800'
|
||||
: 'bg-[var(--tavern-surface)] text-[#f5ede0]/70 hover:text-[#f5ede0]'
|
||||
}`}
|
||||
>
|
||||
{peer.isMuted ? <VolumeX className="w-3 h-3" /> : <Volume2 className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Shield,
|
||||
Crown,
|
||||
UserCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
Mail,
|
||||
User as UserIcon,
|
||||
CheckCircle2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { User, DEMO_ACCOUNTS, UserRole } from '../types/auth';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -35,8 +29,6 @@ export const AuthModal: React.FC<AuthModalProps> = ({
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSignIn = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
@@ -127,34 +119,15 @@ export const AuthModal: React.FC<AuthModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-amber-500/40 rounded-2xl max-w-lg w-full p-6 shadow-2xl space-y-5 animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-amber-500 to-red-600 rounded-xl text-white shadow-lg shadow-amber-950/50">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif tracking-wide text-slate-100">
|
||||
AetherTable Identity Portal
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Sign in to join active campaigns or create a new adventurer account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="AetherTable Identity Portal"
|
||||
subtitle="Sign in to join active campaigns or create a new adventurer account."
|
||||
icon={<Sparkles className="w-5 h-5" />}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex bg-slate-950 p-1 rounded-lg border border-slate-800 font-mono text-xs">
|
||||
<button
|
||||
@@ -327,6 +300,6 @@ export const AuthModal: React.FC<AuthModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
Save,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
X,
|
||||
CloudUpload,
|
||||
Clock,
|
||||
Swords,
|
||||
} from 'lucide-react';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
import {
|
||||
CampaignSnapshot,
|
||||
CampaignSaveMeta,
|
||||
@@ -42,8 +42,6 @@ export const CampaignSaveModal: React.FC<CampaignSaveModalProps> = ({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
@@ -78,25 +76,15 @@ export const CampaignSaveModal: React.FC<CampaignSaveModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-lg w-full max-h-[85vh] overflow-y-auto p-6 shadow-2xl space-y-4 animate-fadeIn">
|
||||
<div className="flex items-start justify-between border-b border-slate-800 pb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold font-serif text-slate-100">Campaign Saves</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Snapshots persist in the campaign database and survive restarts.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Campaign Saves"
|
||||
subtitle="Snapshots persist in the campaign database and survive restarts."
|
||||
icon={<Save className="w-5 h-5" />}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Save current state */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -169,6 +157,6 @@ export const CampaignSaveModal: React.FC<CampaignSaveModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -198,7 +198,8 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-start justify-center pt-24 p-4 cursor-pointer"
|
||||
className="fixed inset-0 bg-black/85 backdrop-blur-md flex items-start justify-center pt-24 p-4 cursor-pointer"
|
||||
style={{ zIndex: 'var(--z-command)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useFocusTrap } from './ui/useFocusTrap';
|
||||
import {
|
||||
BookOpen,
|
||||
Search,
|
||||
@@ -700,53 +701,97 @@ export const CompendiumView: React.FC<CompendiumViewProps> = ({ onSpawnToken })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rich Statblock Detail Modal */}
|
||||
{/* Rich Statblock Detail Modal — nested sheet: ESC/trap via the shared
|
||||
hook, modal-nested rung so it layers above the compendium itself. */}
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-xl w-full max-h-[85vh] overflow-y-auto p-6 shadow-2xl space-y-4 animate-fadeIn">
|
||||
<div className="flex items-start justify-between border-b border-slate-800 pb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold font-serif text-slate-100">{selectedItem.name}</h3>
|
||||
<p className="text-xs text-slate-400 font-mono mt-0.5">
|
||||
{selectedItem.size || (selectedItem.level === 0 ? 'Cantrip' : `Level ${selectedItem.level}`)}{' '}
|
||||
{selectedItem.type || selectedItem.school}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-sans bg-slate-950/60 p-4 rounded-xl border border-slate-800">
|
||||
{selectedItem.description}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-slate-800">
|
||||
{(activeTab === 'monsters' || activeTab === 'animals') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSpawnMonster(selectedItem);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
className="flex items-center space-x-1.5 px-4 py-2 bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Spawn to Canvas</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatblockOverlay
|
||||
item={selectedItem}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
canSpawn={activeTab === 'monsters' || activeTab === 'animals'}
|
||||
onSpawn={() => {
|
||||
handleSpawnMonster(selectedItem);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Nested statblock detail sheet, extracted from CompendiumView so the shared
|
||||
* focus-trap hook can be called unconditionally (hooks are illegal inside a
|
||||
* conditional render in the parent view). Sits on the modal-nested rung.
|
||||
*/
|
||||
function StatblockOverlay({
|
||||
item,
|
||||
onClose,
|
||||
canSpawn,
|
||||
onSpawn,
|
||||
}: {
|
||||
// Matches the parent's selectedItem state (loosely typed compendium entries).
|
||||
item: any;
|
||||
onClose: () => void;
|
||||
canSpawn: boolean;
|
||||
onSpawn: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFocusTrap({ active: true, containerRef, onEscape: onClose });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/85 backdrop-blur-md flex items-center justify-center p-4"
|
||||
style={{ zIndex: 'var(--z-modal-nested)' }}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="vtt-glass-panel rounded-2xl max-w-xl w-full max-h-[85vh] overflow-y-auto vtt-scrollbar p-6 shadow-2xl space-y-4 animate-fadeIn"
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-[var(--tavern-border)] pb-3">
|
||||
<div>
|
||||
<h3 className="vtt-engraved text-xl font-bold">{item.name}</h3>
|
||||
<p className="text-xs text-[var(--rp-parchment-300)] font-mono mt-0.5">
|
||||
{item.size || (item.level === 0 ? 'Cantrip' : `Level ${item.level}`)}{' '}
|
||||
{item.type || item.school}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close statblock"
|
||||
className="p-1.5 text-[var(--rp-parchment-300)] hover:text-white rounded-lg hover:bg-[var(--rp-leather-700)] transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="selectable-text text-xs text-[var(--rp-parchment-200)] leading-relaxed font-sans bg-black/30 p-4 rounded-xl border border-[var(--tavern-border)]">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-[var(--tavern-border)]">
|
||||
{canSpawn && (
|
||||
<button
|
||||
onClick={onSpawn}
|
||||
className="flex items-center space-x-1.5 px-4 py-2 bg-[var(--rp-amber-600)] hover:bg-[var(--rp-amber-500)] text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Spawn to Canvas</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-black/30 hover:bg-[var(--rp-leather-700)] text-[var(--rp-parchment-100)] text-xs font-mono rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,8 @@ export const DiceHistoryPanel: React.FC<DiceHistoryPanelProps> = ({ entries, onC
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-24 left-4 z-30 w-64 pointer-events-auto"
|
||||
className="absolute bottom-24 left-4 w-64 pointer-events-auto"
|
||||
style={{ zIndex: 'var(--z-chrome)' }}
|
||||
data-testid="dice-history-panel"
|
||||
>
|
||||
{/* Header / toggle */}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Scroll,
|
||||
FileText,
|
||||
Eye,
|
||||
Lock,
|
||||
Share2,
|
||||
X,
|
||||
Sparkles,
|
||||
Search,
|
||||
Check,
|
||||
Key,
|
||||
Shield,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { Scroll, Share2, Sparkles, Shield } from 'lucide-react';
|
||||
import { globalAudio } from '../render/audio_manager';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
export interface DigitalHandout {
|
||||
id: string;
|
||||
@@ -89,8 +77,6 @@ export const HandoutManagerModal: React.FC<HandoutManagerModalProps> = ({
|
||||
const [broadcastAlert, setBroadcastAlert] = useState<string | null>(null);
|
||||
const [handouts, setHandouts] = useState<DigitalHandout[]>(INITIAL_HANDOUTS);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeHandout = handouts.find((h) => h.id === selectedHandoutId) || handouts[0];
|
||||
|
||||
const handleShare = (handout: DigitalHandout, target: 'all' | 'party') => {
|
||||
@@ -106,36 +92,17 @@ export const HandoutManagerModal: React.FC<HandoutManagerModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-3xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col font-sans animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/80">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-amber-600 to-rose-700 rounded-xl text-white shadow-lg">
|
||||
<Scroll className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif text-slate-100">
|
||||
Digital Handouts & Secret Notes Vault
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Inspect stylized parchment letters, clue ciphers, and broadcast secrets to the table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Layout: 2 Columns */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Digital Handouts & Secret Notes Vault"
|
||||
subtitle="Inspect stylized parchment letters, clue ciphers, and broadcast secrets to the table."
|
||||
icon={<Scroll className="w-5 h-5" />}
|
||||
size="lg"
|
||||
tone="parchment"
|
||||
>
|
||||
{/* Content Layout: 2 Columns */}
|
||||
<div className="flex min-h-0">
|
||||
{/* Left Column: Handouts List */}
|
||||
<div className="w-1/3 border-r border-slate-800 p-4 space-y-2 overflow-y-auto bg-slate-950/50 font-mono text-xs">
|
||||
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider mb-2">
|
||||
@@ -188,11 +155,13 @@ export const HandoutManagerModal: React.FC<HandoutManagerModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parchment Document Frame */}
|
||||
<div className="bg-amber-50/95 text-slate-900 p-6 rounded-2xl border-4 border-amber-900/30 shadow-2xl relative font-serif space-y-4">
|
||||
{/* Parchment Document Frame — tokenized rustic surface
|
||||
(.vtt-parchment: aged paper gradient, iron frame, candlelight
|
||||
edge burn; see index.css component layer) */}
|
||||
<div className="vtt-parchment p-6 rounded-2xl relative space-y-4">
|
||||
<div className="flex items-start justify-between border-b-2 border-amber-900/20 pb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-amber-950 tracking-wide">
|
||||
<h3 className="text-xl font-extrabold tracking-wide" style={{ color: "var(--parchment-ink)" }}>
|
||||
{activeHandout.title}
|
||||
</h3>
|
||||
<div className="text-[11px] font-mono text-amber-800/80 mt-0.5">
|
||||
@@ -237,7 +206,6 @@ export const HandoutManagerModal: React.FC<HandoutManagerModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -39,7 +39,7 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
switch (iconType) {
|
||||
case 'mage':
|
||||
case 'caster':
|
||||
return <Sparkles className="w-3.5 h-3.5 text-purple-200" />;
|
||||
return <Sparkles className="w-3.5 h-3.5 text-[var(--rp-parchment-200)]" />;
|
||||
case 'boss':
|
||||
return <Skull className="w-3.5 h-3.5 text-rose-200" />;
|
||||
case 'scout':
|
||||
@@ -52,10 +52,10 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<aside aria-label="Initiative Tracker Sidebar" className="h-full vtt-glass-panel border-r border-slate-800/80 p-2 flex flex-col items-center justify-between shrink-0 w-12 transition-all">
|
||||
<aside aria-label="Initiative Tracker Sidebar" className="h-full vtt-glass-panel p-2 flex flex-col items-center justify-between shrink-0 w-12 transition-all">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 bg-slate-900 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-white border border-slate-800 transition"
|
||||
className="p-1.5 bg-black/30 hover:bg-[var(--rp-leather-700)] rounded-lg text-[var(--rp-parchment-300)] hover:text-white border border-[var(--tavern-border)] transition"
|
||||
title="Expand Initiative Tracker"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -67,7 +67,7 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
key={token.id}
|
||||
onClick={() => onSelectToken(token.id)}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center cursor-pointer border transition-transform ${
|
||||
idx === currentTurnIndex ? 'border-purple-400 scale-110 shadow-md shadow-purple-500/50' : 'border-slate-700'
|
||||
idx === currentTurnIndex ? 'border-[var(--tavern-accent)] scale-110 shadow-md shadow-amber-900/50' : 'border-[var(--tavern-border)]'
|
||||
}`}
|
||||
style={{ backgroundColor: token.color }}
|
||||
title={`${token.name} (${token.hp}/${token.maxHp} HP)`}
|
||||
@@ -79,7 +79,7 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
|
||||
<button
|
||||
onClick={onNextTurn}
|
||||
className="p-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg transition"
|
||||
className="p-2 bg-[var(--rp-amber-600)] hover:bg-[var(--rp-amber-500)] text-white rounded-lg transition"
|
||||
title="Advance Turn"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -89,23 +89,23 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside aria-label="Initiative Tracker Sidebar" className="w-72 h-full vtt-glass-panel border-r border-slate-800/80 flex flex-col justify-between shrink-0 transition-all">
|
||||
<aside aria-label="Initiative Tracker Sidebar" className="w-72 h-full vtt-glass-panel flex flex-col justify-between shrink-0 transition-all">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="p-3.5 border-b border-slate-800/80 flex items-center justify-between">
|
||||
<div className="p-3.5 border-b border-[var(--tavern-border)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Swords className="w-4 h-4 text-purple-400" />
|
||||
<span className="font-bold text-xs tracking-wide text-slate-100 font-display">
|
||||
<Swords className="w-4 h-4 text-[var(--tavern-accent)]" />
|
||||
<span className="vtt-engraved font-bold text-xs tracking-wide">
|
||||
Initiative Order
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 bg-purple-950 text-purple-300 rounded border border-purple-800">
|
||||
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 bg-[var(--rp-leather-700)] text-[var(--rp-parchment-200)] rounded border border-[var(--tavern-accent)]/40">
|
||||
Round {roundNumber}
|
||||
</span>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1 text-slate-500 hover:text-slate-200 transition"
|
||||
className="p-1 opacity-60 hover:opacity-100 transition"
|
||||
title="Collapse Panel"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
@@ -126,10 +126,10 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
onClick={() => onSelectToken(token.id)}
|
||||
className={`p-3 rounded-xl transition-all cursor-pointer border ${
|
||||
isActiveTurn
|
||||
? 'bg-purple-950/40 border-purple-500/80 shadow-lg shadow-purple-950/40'
|
||||
? 'bg-[color-mix(in_srgb,var(--tavern-accent)_14%,transparent)] border-[var(--tavern-accent)]/70 shadow-lg shadow-black/40'
|
||||
: isSelected
|
||||
? 'bg-slate-900/90 border-slate-700'
|
||||
: 'bg-slate-900/40 border-slate-800/60 hover:bg-slate-850'
|
||||
? 'bg-[var(--tavern-surface)] border-[var(--tavern-border)]'
|
||||
: 'bg-black/20 border-[var(--tavern-border)]/60 hover:bg-[var(--rp-leather-700)]/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -141,17 +141,17 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
{renderTokenIcon(token)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-xs text-slate-100 flex items-center gap-1.5">
|
||||
<div className="font-semibold text-xs text-[var(--rp-parchment-100)] flex items-center gap-1.5">
|
||||
{token.name}
|
||||
{isDead && <Skull className="w-3 h-3 text-rose-500" />}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-mono">
|
||||
{token.isPlayer ? 'Player Character' : 'Hostile Entity'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs font-mono text-slate-300">
|
||||
<div className="flex items-center gap-1 text-xs font-mono text-[var(--rp-parchment-200)]">
|
||||
<Shield className="w-3.5 h-3.5 text-sky-400" />
|
||||
<span>{token.ac} AC</span>
|
||||
</div>
|
||||
@@ -159,15 +159,15 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
|
||||
{/* HP Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] font-mono text-slate-400">
|
||||
<div className="flex justify-between text-[10px] font-mono text-[var(--rp-parchment-300)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-2.5 h-2.5 text-rose-400" /> Health
|
||||
</span>
|
||||
<span className="font-bold text-slate-200">
|
||||
<span className="font-bold text-[var(--rp-parchment-100)]">
|
||||
{Math.max(0, token.hp)} / {token.maxHp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-950 rounded-full overflow-hidden border border-slate-800">
|
||||
<div className="w-full h-1.5 bg-black/40 rounded-full overflow-hidden border border-[var(--tavern-border)]">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
token.hp / token.maxHp > 0.5
|
||||
@@ -187,10 +187,10 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer Next Turn Button */}
|
||||
<div className="p-3 border-t border-slate-800/80 bg-slate-950/90">
|
||||
<div className="p-3 border-t border-[var(--tavern-border)] bg-black/30">
|
||||
<button
|
||||
onClick={onNextTurn}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-3 bg-purple-600 hover:bg-purple-500 active:bg-purple-700 text-white font-semibold text-xs rounded-lg transition shadow-md shadow-purple-950 border border-purple-400/30"
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-3 bg-[var(--rp-amber-600)] hover:bg-[var(--rp-amber-500)] active:bg-[var(--tavern-accent-deep)] text-white font-semibold text-xs rounded-lg transition shadow-md shadow-amber-950 border border-amber-400/30"
|
||||
>
|
||||
<span>Advance Turn</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
const quickMacros = [
|
||||
{ name: 'Initiative', formula: '1d20 + 2', icon: <Zap className="w-3 h-3 text-amber-400" /> },
|
||||
{ name: 'Perception', formula: '1d20 + 1', icon: <Eye className="w-3 h-3 text-sky-400" /> },
|
||||
{ name: 'Stealth', formula: '1d20 + 2', icon: <EyeOff className="w-3 h-3 text-purple-400" /> },
|
||||
{ name: 'Stealth', formula: '1d20 + 2', icon: <EyeOff className="w-3 h-3 text-[var(--tavern-accent)]" /> },
|
||||
{ name: 'Athletics', formula: '1d20 + 4', icon: <Shield className="w-3 h-3 text-emerald-400" /> },
|
||||
{ name: 'Death Save', formula: '1d20', icon: <Skull className="w-3 h-3 text-rose-400" /> },
|
||||
{ name: 'Greataxe', formula: '1d12 + 4', icon: <Sword className="w-3 h-3 text-rose-400" /> },
|
||||
@@ -53,22 +53,25 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 transition-all select-none">
|
||||
<div className="bg-slate-950/95 border border-slate-800/90 shadow-2xl rounded-2xl p-2.5 backdrop-blur-md flex flex-col items-center gap-2 max-w-2xl w-full">
|
||||
<div
|
||||
className="absolute bottom-20 left-1/2 -translate-x-1/2 transition-all select-none"
|
||||
style={{ zIndex: 'var(--z-chrome)' }}
|
||||
>
|
||||
<div className="vtt-glass-panel shadow-2xl rounded-2xl p-2.5 flex flex-col items-center gap-2 max-w-2xl w-full">
|
||||
{/* Toggle Collapse Bar */}
|
||||
<div className="flex items-center justify-between w-full px-2 text-[10px] font-mono text-slate-400 border-b border-slate-800/60 pb-1.5">
|
||||
<div className="flex items-center justify-between w-full px-2 text-[10px] font-mono text-[var(--rp-parchment-300)] border-b border-[var(--tavern-border)] pb-1.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Dices className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="font-bold tracking-wider uppercase text-slate-300">Roll20 Macro Quickbar</span>
|
||||
<span className="font-bold tracking-wider uppercase text-[var(--rp-parchment-200)]">Roll20 Macro Quickbar</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Advantage / Disadvantage Toggle */}
|
||||
<div className="flex bg-slate-900 rounded-lg p-0.5 border border-slate-800">
|
||||
<div className="flex bg-black/30 rounded-lg p-0.5 border border-[var(--tavern-border)]">
|
||||
<button
|
||||
onClick={() => setAdvDis('advantage')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition cursor-pointer ${
|
||||
advDis === 'advantage' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-slate-200'
|
||||
advDis === 'advantage' ? 'bg-emerald-600 text-white' : 'text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)]'
|
||||
}`}
|
||||
title="Roll with Advantage (Roll 2d20, take highest)"
|
||||
>
|
||||
@@ -77,7 +80,7 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
<button
|
||||
onClick={() => setAdvDis('normal')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition cursor-pointer ${
|
||||
advDis === 'normal' ? 'bg-slate-750 text-slate-200' : 'text-slate-500 hover:text-slate-300'
|
||||
advDis === 'normal' ? 'bg-[var(--rp-leather-600)] text-white' : 'text-[var(--rp-parchment-300)] opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
NORM
|
||||
@@ -85,7 +88,7 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
<button
|
||||
onClick={() => setAdvDis('disadvantage')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition cursor-pointer ${
|
||||
advDis === 'disadvantage' ? 'bg-rose-600 text-white' : 'text-slate-400 hover:text-slate-200'
|
||||
advDis === 'disadvantage' ? 'bg-rose-600 text-white' : 'text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)]'
|
||||
}`}
|
||||
title="Roll with Disadvantage (Roll 2d20, take lowest)"
|
||||
>
|
||||
@@ -97,17 +100,17 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
<button
|
||||
onClick={() => setIsWhisper(!isWhisper)}
|
||||
className={`flex items-center space-x-1 px-2 py-0.5 rounded border text-[10px] font-bold transition cursor-pointer ${
|
||||
isWhisper ? 'bg-purple-950 text-purple-300 border-purple-600' : 'bg-slate-900 text-slate-400 border-slate-800'
|
||||
isWhisper ? 'bg-[var(--rp-leather-700)] text-[var(--rp-parchment-200)] border-[var(--tavern-accent)]/60' : 'bg-black/30 text-[var(--rp-parchment-300)] border-[var(--tavern-border)]'
|
||||
}`}
|
||||
title="Whisper roll results privately to the Dungeon Master"
|
||||
>
|
||||
{isWhisper ? <EyeOff className="w-3 h-3 text-purple-400" /> : <Eye className="w-3 h-3" />}
|
||||
{isWhisper ? <EyeOff className="w-3 h-3 text-[var(--tavern-accent)]" /> : <Eye className="w-3 h-3" />}
|
||||
<span>{isWhisper ? 'Whisper GM' : 'Public'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-slate-500 hover:text-slate-300 transition cursor-pointer"
|
||||
className="text-[var(--rp-parchment-300)] opacity-70 hover:opacity-100 transition cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronUp className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
@@ -121,11 +124,11 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
<button
|
||||
key={macro.name}
|
||||
onClick={() => handleTriggerQuickMacro(macro.name, macro.formula)}
|
||||
className="flex items-center space-x-1 px-2.5 py-1.5 bg-slate-900/90 hover:bg-slate-800 border border-slate-800 hover:border-amber-500/50 rounded-lg text-[11px] font-mono font-medium text-slate-200 hover:text-amber-200 transition-all cursor-pointer shadow-sm group active:scale-95"
|
||||
className="flex items-center space-x-1 px-2.5 py-1.5 bg-black/30 hover:bg-[var(--rp-leather-700)] border border-[var(--tavern-border)] hover:border-[var(--tavern-accent)]/50 rounded-lg text-[11px] font-mono font-medium text-[var(--rp-parchment-200)] hover:text-amber-200 transition-all cursor-pointer shadow-sm group active:scale-95"
|
||||
>
|
||||
{macro.icon}
|
||||
<span>{macro.name}</span>
|
||||
<span className="text-[9px] text-slate-500 font-normal">({macro.formula})</span>
|
||||
<span className="text-[9px] opacity-60 font-normal">({macro.formula})</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -139,7 +142,7 @@ export const MacroQuickbar: React.FC<MacroQuickbarProps> = ({ onExecuteRoll }) =
|
||||
placeholder="/roll 2d6 + 4"
|
||||
value={customFormula}
|
||||
onChange={(e) => setCustomFormula(e.target.value)}
|
||||
className="w-28 bg-slate-900 border border-slate-700 rounded-lg px-2 py-1 text-[11px] font-mono text-slate-200 focus:outline-none focus:border-amber-500"
|
||||
className="w-28 bg-black/30 border border-[var(--tavern-border)] rounded-lg px-2 py-1 text-[11px] font-mono text-[var(--rp-parchment-100)] focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Unlock,
|
||||
Shield,
|
||||
Square,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
Sparkles,
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
PenTool,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { Layers, Shield, Plus, Trash2, PenTool, Check } from 'lucide-react';
|
||||
import { globalAudio } from '../render/audio_manager';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
export type MapLayerType = 'map' | 'walls' | 'tokens' | 'gm_hidden';
|
||||
|
||||
@@ -45,8 +30,6 @@ export const MapLayerEditorModal: React.FC<MapLayerEditorModalProps> = ({
|
||||
const [newX, setNewX] = useState('5');
|
||||
const [newY, setNewY] = useState('4');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const layers: { id: MapLayerType; name: string; icon: string; description: string; color: string }[] = [
|
||||
{
|
||||
id: 'map',
|
||||
@@ -98,36 +81,26 @@ export const MapLayerEditorModal: React.FC<MapLayerEditorModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col font-sans animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/80">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-amber-500 to-amber-700 rounded-xl text-slate-950 shadow-lg">
|
||||
<Layers className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif text-slate-100">
|
||||
Multi-Layer Tactical Map & LoS Editor
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Roll20 style layer switcher, dynamic line-of-sight wall drawing, and GM hidden annotations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Multi-Layer Tactical Map & LoS Editor"
|
||||
subtitle="Roll20 style layer switcher, dynamic line-of-sight wall drawing, and GM hidden annotations."
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
className="px-5 py-2 bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
Apply & Close Editor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body Content */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
|
||||
}
|
||||
>
|
||||
{/* Body Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Layer Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-mono font-bold text-slate-300 uppercase tracking-wider">
|
||||
@@ -227,32 +200,21 @@ export const MapLayerEditorModal: React.FC<MapLayerEditorModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GM Hidden Info Layer View */}
|
||||
{activeLayer === 'gm_hidden' && (
|
||||
<div className="p-4 bg-rose-950/30 border border-rose-900/50 rounded-xl space-y-2 text-xs font-mono text-rose-200">
|
||||
<div className="font-bold flex items-center space-x-1.5">
|
||||
<Shield className="w-4 h-4 text-rose-400" />
|
||||
<span>GM Hidden Annotations Active</span>
|
||||
</div>
|
||||
<p className="text-slate-300 font-sans text-xs">
|
||||
• [Trap DC 15 Investigation]: Spiked pit trigger at grid [D4].<br />
|
||||
• [Ambush Trigger]: 2x Shadow Wraiths appear when token crosses tile [G6].<br />
|
||||
• [Secret Door DC 14 Perception]: Concealed revolving stone behind altar at [H2].
|
||||
</p>
|
||||
{/* GM Hidden Info Layer View */}
|
||||
{activeLayer === 'gm_hidden' && (
|
||||
<div className="p-4 bg-rose-950/30 border border-rose-900/50 rounded-xl space-y-2 text-xs font-mono text-rose-200">
|
||||
<div className="font-bold flex items-center space-x-1.5">
|
||||
<Shield className="w-4 h-4 text-rose-400" />
|
||||
<span>GM Hidden Annotations Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-slate-950/80 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
Apply & Close Editor
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-slate-300 font-sans text-xs">
|
||||
• [Trap DC 15 Investigation]: Spiked pit trigger at grid [D4].<br />
|
||||
• [Ambush Trigger]: 2x Shadow Wraiths appear when token crosses tile [G6].<br />
|
||||
• [Secret Door DC 14 Perception]: Concealed revolving stone behind altar at [H2].
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -119,7 +119,11 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<header ref={navRef} className="h-14 border-b border-slate-800 bg-slate-950/95 backdrop-blur-md px-4 flex items-center justify-between z-30 shrink-0 select-none shadow-md">
|
||||
<header
|
||||
ref={navRef}
|
||||
className="h-14 bg-[color-mix(in_srgb,var(--tavern-bg)_95%,transparent)] backdrop-blur-md px-4 flex items-center justify-between shrink-0 select-none shadow-md"
|
||||
style={{ zIndex: 'var(--z-chrome)', borderBottom: '1px solid var(--tavern-border)' }}
|
||||
>
|
||||
{/* Brand & Global Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -132,14 +136,14 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5 leading-none">
|
||||
<span className="font-extrabold text-sm tracking-tight text-slate-100 font-serif">
|
||||
<span className="vtt-engraved font-extrabold text-sm tracking-tight">
|
||||
AetherTable
|
||||
</span>
|
||||
<span className="text-[9px] uppercase font-mono px-1.5 py-0.2 rounded bg-amber-950 text-amber-300 border border-amber-600/50 font-bold">
|
||||
SaaS
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono mt-0.5 truncate max-w-[130px]">
|
||||
<span className="text-[10px] text-[var(--rp-parchment-300)] font-mono mt-0.5 truncate max-w-[130px]">
|
||||
{campaignName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -149,12 +153,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
{onOpenCommandPalette && (
|
||||
<button
|
||||
onClick={onOpenCommandPalette}
|
||||
className="hidden md:flex items-center space-x-2 px-2.5 py-1 bg-slate-900/90 hover:bg-slate-850 border border-slate-800 hover:border-amber-500/50 text-slate-400 hover:text-slate-200 rounded-lg text-xs font-mono transition shadow-inner cursor-pointer"
|
||||
className="hidden md:flex items-center space-x-2 px-2.5 py-1 bg-[var(--tavern-surface)] hover:bg-[var(--rp-leather-700)] border border-[var(--tavern-border)] hover:border-[var(--tavern-accent)]/50 text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)] rounded-lg text-xs font-mono transition shadow-inner cursor-pointer"
|
||||
title="Open Universal Search Palette (Cmd+K)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="hidden xl:inline">Search...</span>
|
||||
<span className="text-[9px] px-1 py-0.2 bg-slate-950 border border-slate-700 text-slate-400 rounded">
|
||||
<span className="text-[9px] px-1 py-0.2 bg-black/40 border border-[var(--tavern-border)] text-[var(--rp-parchment-300)] rounded">
|
||||
⌘K
|
||||
</span>
|
||||
</button>
|
||||
@@ -169,7 +173,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-bold transition-all cursor-pointer ${
|
||||
currentView === 'tabletop'
|
||||
? 'bg-amber-600 text-white shadow-md shadow-amber-950 border border-amber-400/40'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-900'
|
||||
: 'text-[var(--rp-parchment-200)] hover:text-white hover:bg-[var(--tavern-surface)]'
|
||||
}`}
|
||||
>
|
||||
<Swords className="w-4 h-4 text-amber-400" />
|
||||
@@ -183,8 +187,8 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onClick={() => toggleDropdown('compendium')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-semibold transition cursor-pointer ${
|
||||
activeDropdown === 'compendium' || ['compendium', 'bundles', 'dynasty'].includes(currentView)
|
||||
? 'bg-slate-850 text-amber-300 border border-slate-700'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
? 'bg-[var(--tavern-surface)] text-[var(--tavern-accent)] border border-[var(--tavern-border)]'
|
||||
: 'text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)] hover:bg-[var(--tavern-surface)]'
|
||||
}`}
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
@@ -193,15 +197,15 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</button>
|
||||
|
||||
{activeDropdown === 'compendium' && (
|
||||
<div className="absolute left-0 top-11 w-56 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-1.5 z-50 animate-fadeIn space-y-1">
|
||||
<div className="absolute left-0 top-11 w-56 vtt-glass-panel rounded-xl shadow-2xl p-1.5 animate-fadeIn space-y-1" style={{ zIndex: 'var(--z-popover)' }}>
|
||||
<button
|
||||
onClick={() => handleNavClick('compendium')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<BookOpen className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Compendium Codex</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">319 Spells & 318 Monsters</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">319 Spells & 318 Monsters</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -211,12 +215,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenQuestJournal();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<BookOpen className="w-4 h-4 text-sky-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Quest & Campaign Journal</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Active Objectives & NPC Dossiers</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Active Objectives & NPC Dossiers</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -227,35 +231,35 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenHandouts();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Scroll className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Digital Handouts Vault</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Parchment Clues & Letters</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Parchment Clues & Letters</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleNavClick('bundles')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Package className="w-4 h-4 text-sky-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Campaign Bundles</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">.vttbundle Exporter/Importer</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">.vttbundle Exporter/Importer</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavClick('dynasty')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Crown className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Dynasty & Factions</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Noble Houses & Feud Matrix</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Noble Houses & Feud Matrix</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -268,8 +272,8 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onClick={() => toggleDropdown('characters')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-semibold transition cursor-pointer ${
|
||||
activeDropdown === 'characters' || ['builder', 'encounters', 'lobby'].includes(currentView)
|
||||
? 'bg-slate-850 text-amber-300 border border-slate-700'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
? 'bg-[var(--tavern-surface)] text-[var(--tavern-accent)] border border-[var(--tavern-border)]'
|
||||
: 'text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)] hover:bg-[var(--tavern-surface)]'
|
||||
}`}
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
@@ -278,37 +282,37 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</button>
|
||||
|
||||
{activeDropdown === 'characters' && (
|
||||
<div className="absolute left-0 top-11 w-56 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-1.5 z-50 animate-fadeIn space-y-1">
|
||||
<div className="absolute left-0 top-11 w-56 vtt-glass-panel rounded-xl shadow-2xl p-1.5 animate-fadeIn space-y-1" style={{ zIndex: 'var(--z-popover)' }}>
|
||||
<button
|
||||
onClick={() => handleNavClick('builder')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-purple-400 shrink-0" />
|
||||
<Sparkles className="w-4 h-4 text-[var(--tavern-accent)] shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Character Studio</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">5-Step Wizard & Point Buy</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">5-Step Wizard & Point Buy</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavClick('encounters')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Encounter Builder</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">XP Budgets & CR Thresholds</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">XP Budgets & CR Thresholds</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavClick('lobby')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Users className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Campaign Lobby</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Multiplayer Seat Claiming</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Multiplayer Seat Claiming</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -321,7 +325,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-semibold transition cursor-pointer ${
|
||||
currentView === 'marketplace'
|
||||
? 'bg-amber-600 text-white shadow border border-amber-400/40'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
: 'text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)] hover:bg-[var(--tavern-surface)]'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 text-emerald-400" />
|
||||
@@ -334,8 +338,8 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onClick={() => toggleDropdown('gm_studio')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-semibold transition cursor-pointer ${
|
||||
activeDropdown === 'gm_studio' || ['wfc', 'quests', 'analytics', 'admin'].includes(currentView)
|
||||
? 'bg-slate-850 text-amber-300 border border-slate-700'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
? 'bg-[var(--tavern-surface)] text-[var(--tavern-accent)] border border-[var(--tavern-border)]'
|
||||
: 'text-[var(--rp-parchment-300)] hover:text-[var(--rp-parchment-100)] hover:bg-[var(--tavern-surface)]'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
@@ -344,26 +348,26 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</button>
|
||||
|
||||
{activeDropdown === 'gm_studio' && (
|
||||
<div className="absolute left-0 top-11 w-56 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-1.5 z-50 animate-fadeIn space-y-1">
|
||||
<div className="absolute left-0 top-11 w-56 vtt-glass-panel rounded-xl shadow-2xl p-1.5 animate-fadeIn space-y-1" style={{ zIndex: 'var(--z-popover)' }}>
|
||||
<button
|
||||
onClick={() => handleNavClick('wfc')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Layers className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">WFC Dungeon Studio</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Procedural Dungeon Synthesis</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Procedural Dungeon Synthesis</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavClick('quests')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Scroll className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Quest & Dialogue Trees</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Branching Concordia NPC Pacts</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Branching Concordia NPC Pacts</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -373,36 +377,36 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenMapEditor();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<PenToolIcon className="w-4 h-4 text-teal-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Map & LoS Editor</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">4-Layer Drawing & Wall Cells</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">4-Layer Drawing & Wall Cells</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleNavClick('analytics')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<LineChart className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">SLA Telemetry</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Latency & Reliability Metrics</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Latency & Reliability Metrics</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{currentUser?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => handleNavClick('admin')}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-rose-950/50 transition text-rose-300 cursor-pointer border-t border-slate-800 pt-2"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-rose-950/50 transition text-rose-300 cursor-pointer border-t border-[var(--tavern-border)] pt-2"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Admin Console</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Cluster & RBAC Controls</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Cluster & RBAC Controls</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -420,7 +424,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-mono transition cursor-pointer ${
|
||||
activeDropdown === 'tools'
|
||||
? 'bg-amber-950/80 text-amber-300 border-amber-600/60 shadow'
|
||||
: 'bg-slate-900/90 text-slate-300 border-slate-800 hover:bg-slate-800'
|
||||
: 'bg-[var(--tavern-surface)] text-[var(--rp-parchment-200)] border-[var(--tavern-border)] hover:bg-[var(--rp-leather-700)]'
|
||||
}`}
|
||||
title="Tabletop Audio, Soundscapes, Map Layers & Streamer Tools"
|
||||
>
|
||||
@@ -430,19 +434,19 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</button>
|
||||
|
||||
{activeDropdown === 'tools' && (
|
||||
<div className="absolute right-0 top-11 w-60 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-1.5 z-50 animate-fadeIn space-y-1 font-mono text-xs">
|
||||
<div className="absolute right-0 top-11 w-60 vtt-glass-panel rounded-xl shadow-2xl p-1.5 animate-fadeIn space-y-1 font-mono text-xs" style={{ zIndex: 'var(--z-popover)' }}>
|
||||
{onOpenCampaignSaves && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenCampaignSaves();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Save className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Campaign Saves</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Persist & resume sessions</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Persist & resume sessions</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -453,12 +457,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenJukebox();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Music className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Jukebox & Soundscapes</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Ambient multi-track audio</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Ambient multi-track audio</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -469,12 +473,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenMapEditor();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Layers className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Map & LoS Layers</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Dynamic lighting editor</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Dynamic lighting editor</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -485,12 +489,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenHandouts();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Scroll className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Handouts Vault</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Secret notes & parchment</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Secret notes & parchment</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -501,12 +505,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenStreamerHUD();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Video className="w-4 h-4 text-red-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">Streamer Broadcast Mode</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">OBS clean & Discord webhooks</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">OBS clean & Discord webhooks</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -517,12 +521,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onToggleVideoMesh();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Users className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">WebRTC Video Mesh</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Player camera & audio tiles</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Player camera & audio tiles</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -533,12 +537,12 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenAudioMixer();
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-slate-800 transition text-slate-200 cursor-pointer"
|
||||
className="w-full flex items-center space-x-2.5 p-2 rounded-lg text-left hover:bg-[var(--rp-leather-700)] transition text-[var(--rp-parchment-200)] cursor-pointer"
|
||||
>
|
||||
<Radio className="w-4 h-4 text-purple-400 shrink-0" />
|
||||
<Radio className="w-4 h-4 text-[var(--tavern-accent)] shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold">3D Audio Radar</div>
|
||||
<div className="text-[10px] text-slate-400 font-sans">Spatial stereo mixer</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] font-sans">Spatial stereo mixer</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -551,40 +555,41 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center space-x-2 p-1.5 bg-slate-900 hover:bg-slate-850 border border-slate-700 hover:border-amber-500/60 rounded-xl transition cursor-pointer"
|
||||
className="flex items-center space-x-2 p-1.5 bg-[var(--tavern-surface)] hover:bg-[var(--rp-leather-700)] border border-[var(--tavern-border)] hover:border-[var(--tavern-accent)]/60 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-amber-500 to-amber-700 flex items-center justify-center text-slate-950 font-bold text-xs shadow">
|
||||
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-amber-500 to-amber-700 flex items-center justify-center text-[var(--rp-ink-900)] font-bold text-xs shadow">
|
||||
{currentUser.displayName.charAt(0)}
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-col text-left">
|
||||
<span className="text-[11px] font-bold text-slate-200 leading-none">
|
||||
<span className="text-[11px] font-bold text-[var(--rp-parchment-100)] leading-none">
|
||||
{currentUser.displayName.split(' ')[0]}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-amber-400 uppercase font-semibold">
|
||||
{currentUser.role}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="w-3 h-3 text-slate-400" />
|
||||
<ChevronDown className="w-3 h-3 text-[var(--rp-parchment-300)]" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 top-12 w-64 bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl p-2 z-50 animate-fadeIn space-y-1 font-mono text-xs">
|
||||
<div className="p-2 border-b border-slate-800 text-left">
|
||||
<div className="font-bold text-slate-100">{currentUser.displayName}</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">{currentUser.email}</div>
|
||||
<div className="absolute right-0 top-12 w-64 vtt-glass-panel rounded-2xl shadow-2xl p-2 animate-fadeIn space-y-1 font-mono text-xs" style={{ zIndex: 'var(--z-popover)' }}>
|
||||
<div className="p-2 border-b border-[var(--tavern-border)] text-left">
|
||||
<div className="font-bold text-[var(--rp-parchment-100)]">{currentUser.displayName}</div>
|
||||
<div className="text-[10px] text-[var(--rp-parchment-300)] truncate">{currentUser.email}</div>
|
||||
<div className="flex items-center space-x-1.5 mt-1">
|
||||
<span className="px-1.5 py-0.2 bg-amber-950 text-amber-300 border border-amber-600/50 rounded text-[9px] font-bold uppercase">
|
||||
{currentUser.role}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.2 bg-purple-950 text-purple-300 border border-purple-600/50 rounded text-[9px] font-bold uppercase">
|
||||
{/* Subscription badge: aged-leather chip instead of the old violet. */}
|
||||
<span className="px-1.5 py-0.2 bg-[var(--rp-leather-700)] text-[var(--rp-parchment-200)] border border-[var(--rp-amber-600)]/40 rounded text-[9px] font-bold uppercase">
|
||||
{currentUser.subscriptionTier} Tier
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Switch Perspective (Multiplayer Simulator) */}
|
||||
<div className="p-1 text-[10px] text-slate-400 font-bold uppercase tracking-wider">
|
||||
<div className="p-1 text-[10px] text-[var(--rp-parchment-300)] font-bold uppercase tracking-wider">
|
||||
Switch Client Perspective
|
||||
</div>
|
||||
{DEMO_ACCOUNTS.map((demo) => (
|
||||
@@ -595,7 +600,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-left transition cursor-pointer ${
|
||||
currentUser.id === demo.user.id ? 'bg-amber-950/60 text-amber-300 font-bold' : 'hover:bg-slate-800 text-slate-300'
|
||||
currentUser.id === demo.user.id ? 'bg-amber-950/60 text-amber-300 font-bold' : 'hover:bg-[var(--rp-leather-700)] text-[var(--rp-parchment-200)]'
|
||||
}`}
|
||||
>
|
||||
<span>{demo.user.displayName.split(' ')[0]}</span>
|
||||
@@ -603,14 +608,14 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="border-t border-slate-800 pt-1">
|
||||
<div className="border-t border-[var(--tavern-border)] pt-1">
|
||||
{onOpenUserSettings && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenUserSettings();
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2 px-2 py-1.5 rounded-lg text-slate-300 hover:bg-slate-800 hover:text-white transition cursor-pointer"
|
||||
className="w-full flex items-center space-x-2 px-2 py-1.5 rounded-lg text-[var(--rp-parchment-200)] hover:bg-[var(--rp-leather-700)] hover:text-white transition cursor-pointer"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
<span>User Settings</span>
|
||||
@@ -636,7 +641,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onOpenAuth();
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center space-x-2 px-2 py-1.5 rounded-lg text-slate-400 hover:bg-slate-800 hover:text-rose-400 transition cursor-pointer"
|
||||
className="w-full flex items-center space-x-2 px-2 py-1.5 rounded-lg text-[var(--rp-parchment-300)] hover:bg-[var(--rp-leather-700)] hover:text-rose-400 transition cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Switch Account / Sign Out</span>
|
||||
@@ -662,8 +667,8 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
onClick={toggleMute}
|
||||
className={`p-1.5 rounded-lg border transition cursor-pointer ${
|
||||
isMuted
|
||||
? 'bg-slate-900 text-rose-400 border-slate-800'
|
||||
: 'bg-slate-900 text-amber-400 border-slate-800 hover:text-white'
|
||||
? 'bg-[var(--tavern-surface)] text-rose-400 border-[var(--tavern-border)]'
|
||||
: 'bg-[var(--tavern-surface)] text-amber-400 border-[var(--tavern-border)] hover:text-white'
|
||||
}`}
|
||||
title={isMuted ? 'Unmute Audio Cues' : 'Mute Audio Cues'}
|
||||
>
|
||||
@@ -671,7 +676,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Live Latency Status */}
|
||||
<div className="hidden 2xl:flex items-center gap-1.5 px-2 py-1 rounded-lg bg-slate-900/80 border border-slate-800 text-[11px] font-mono text-slate-300 shadow-inner">
|
||||
<div className="hidden 2xl:flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/30 border border-[var(--tavern-border)] text-[11px] font-mono text-[var(--rp-parchment-200)] shadow-inner">
|
||||
<Wifi className="w-3 h-3 text-emerald-400 animate-pulse" />
|
||||
<span>Sync: <strong className="text-emerald-400">{latencyMs}ms</strong></span>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Coins,
|
||||
Share2,
|
||||
X,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Shield,
|
||||
Search,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { globalAudio } from '../render/audio_manager';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
export interface QuestItem {
|
||||
id: string;
|
||||
@@ -151,8 +143,6 @@ export const QuestJournalModal: React.FC<QuestJournalModalProps> = ({
|
||||
],
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeQuest = quests.find((q) => q.id === selectedQuestId) || quests[0];
|
||||
|
||||
const handleToggleStep = (stepIdx: number) => {
|
||||
@@ -176,35 +166,25 @@ export const QuestJournalModal: React.FC<QuestJournalModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-4xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col font-sans animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/80">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-amber-500 to-indigo-600 rounded-xl text-white shadow-lg">
|
||||
<BookOpen className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif text-slate-100">
|
||||
Campaign Notes & Interactive Quest Journal
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Active quest objectives, NPC dossiers, session chronicles, and party treasury ledger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Campaign Notes & Interactive Quest Journal"
|
||||
subtitle="Active quest objectives, NPC dossiers, session chronicles, and party treasury ledger."
|
||||
icon={<BookOpen className="w-5 h-5" />}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
className="px-5 py-2 bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
Close Journal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
}
|
||||
>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-slate-800 bg-slate-950 px-6 pt-2 font-mono text-xs space-x-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('quests')}
|
||||
@@ -446,17 +426,6 @@ export const QuestJournalModal: React.FC<QuestJournalModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-slate-950/80 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
Close Journal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertOctagon, RotateCcw, FastForward, ShieldAlert, X } from 'lucide-react';
|
||||
import { AlertOctagon, RotateCcw, ShieldAlert } from 'lucide-react';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface SafetyModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,8 +15,6 @@ export const SafetyModal: React.FC<SafetyModalProps> = ({
|
||||
}) => {
|
||||
const [selectedTopic, setSelectedTopic] = useState('General Content Warning');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const topics = [
|
||||
'General Content Warning',
|
||||
'Arachnophobia / Spiders',
|
||||
@@ -25,26 +24,37 @@ export const SafetyModal: React.FC<SafetyModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-slate-900 border-2 border-rose-600 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||
{/* Header */}
|
||||
<div className="p-4 bg-rose-950/60 border-b border-rose-900/80 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-rose-300 font-bold text-base">
|
||||
<AlertOctagon className="w-5 h-5 text-rose-500" />
|
||||
<span>Safety Gateway (X-Card Active)</span>
|
||||
</div>
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Safety Gateway (X-Card Active)"
|
||||
subtitle="Pause the narrative and rewind to before the triggering moment."
|
||||
icon={<AlertOctagon className="w-5 h-5" />}
|
||||
size="sm"
|
||||
footer={
|
||||
/* Actions */
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="text-slate-400 hover:text-white transition"
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onTriggerRewind(selectedTopic);
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-1.5 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-lg shadow-rose-950"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Rewind Scene & Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 text-xs text-slate-300">
|
||||
}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="space-y-4 text-xs text-slate-300">
|
||||
<p>
|
||||
Invoking the <strong>X-Card</strong> instantly pauses narrative generation, removes the triggering topic from context memory, and executes an authoritative state rewind to the preceding game event.
|
||||
</p>
|
||||
@@ -69,27 +79,6 @@ export const SafetyModal: React.FC<SafetyModalProps> = ({
|
||||
<span>No explanation is required. The system will seamlessly resume with the trigger removed.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 bg-slate-950 border-t border-slate-800 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onTriggerRewind(selectedTopic);
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-1.5 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-lg shadow-rose-950"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Rewind Scene & Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Keyboard, X } from 'lucide-react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface ShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -38,67 +39,47 @@ const SHORTCUT_GROUPS: { group: string; rows: ShortcutRow[] }[] = [
|
||||
];
|
||||
|
||||
export const ShortcutsModal: React.FC<ShortcutsModalProps> = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Keyboard shortcuts"
|
||||
onClick={onClose}
|
||||
/* nested: this cheat-sheet can be opened from inside another modal (e.g.
|
||||
the command palette), so it layers on the --z-modal-nested rung. */
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Keyboard Shortcuts"
|
||||
icon={<Keyboard className="w-5 h-5" />}
|
||||
size="md"
|
||||
nested={true}
|
||||
>
|
||||
<div
|
||||
className="vtt-glass-panel rounded-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto vtt-scrollbar"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 flex items-center justify-between px-5 py-4 border-b border-slate-800 bg-slate-900/90 backdrop-blur rounded-t-2xl">
|
||||
<h2 className="flex items-center gap-2 text-base font-bold text-slate-100">
|
||||
<Keyboard className="w-5 h-5 text-amber-400" aria-hidden="true" />
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close keyboard shortcuts"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{SHORTCUT_GROUPS.map(({ group, rows }) => (
|
||||
<section key={group}>
|
||||
<h3 className="text-[11px] font-mono uppercase tracking-widest text-slate-500 mb-2">
|
||||
{group}
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{rows.map(({ keys, description }) => (
|
||||
<li
|
||||
key={description}
|
||||
className="flex items-center justify-between gap-4 py-1.5 px-2 rounded-lg hover:bg-slate-800/40"
|
||||
>
|
||||
<span className="text-xs text-slate-300">{description}</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="min-w-[1.75rem] text-center px-1.5 py-0.5 rounded-md bg-slate-800 border border-slate-700 border-b-2 text-[10px] font-mono font-semibold text-slate-200 shadow-sm"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{/* Groups */}
|
||||
<div className="space-y-5">
|
||||
{SHORTCUT_GROUPS.map(({ group, rows }) => (
|
||||
<section key={group}>
|
||||
<h3 className="text-[11px] font-mono uppercase tracking-widest text-slate-500 mb-2">
|
||||
{group}
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{rows.map(({ keys, description }) => (
|
||||
<li
|
||||
key={description}
|
||||
className="flex items-center justify-between gap-4 py-1.5 px-2 rounded-lg hover:bg-white/5"
|
||||
>
|
||||
<span className="text-xs text-slate-300">{description}</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="min-w-[1.75rem] text-center px-1.5 py-0.5 rounded-md bg-slate-800 border border-slate-700 border-b-2 text-[10px] font-mono font-semibold text-slate-200 shadow-sm"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -2,21 +2,17 @@ import React, { useState } from 'react';
|
||||
import {
|
||||
Music,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Play,
|
||||
Pause,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Flame,
|
||||
CloudRain,
|
||||
Skull,
|
||||
Swords,
|
||||
X,
|
||||
Radio,
|
||||
Zap,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { globalAudio } from '../render/audio_manager';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface SoundscapeJukeboxModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -29,7 +25,6 @@ interface SoundscapeTrack {
|
||||
category: 'ambient' | 'combat' | 'exploration';
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
color: string;
|
||||
bpm: number;
|
||||
}
|
||||
|
||||
@@ -46,16 +41,13 @@ export const SoundscapeJukeboxModal: React.FC<SoundscapeJukeboxModalProps> = ({
|
||||
const [fxVolume, setFxVolume] = useState(90);
|
||||
const [musicVolume, setMusicVolume] = useState(65);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const tracks: SoundscapeTrack[] = [
|
||||
{
|
||||
id: 'tavern',
|
||||
name: 'Cozy Tavern Hearth',
|
||||
category: 'ambient',
|
||||
icon: <Flame className="w-5 h-5 text-amber-400" />,
|
||||
icon: <Flame className="w-5 h-5 text-[var(--tavern-accent)]" />,
|
||||
description: 'Crackling hearthfire, warm acoustic lute melodies, and joyful laughter.',
|
||||
color: 'from-amber-600 to-orange-700',
|
||||
bpm: 90,
|
||||
},
|
||||
{
|
||||
@@ -64,16 +56,14 @@ export const SoundscapeJukeboxModal: React.FC<SoundscapeJukeboxModalProps> = ({
|
||||
category: 'exploration',
|
||||
icon: <CloudRain className="w-5 h-5 text-sky-400" />,
|
||||
description: 'Heavy rainfall on pines, rolling thunderclaps, and howling autumn wind.',
|
||||
color: 'from-sky-600 to-indigo-800',
|
||||
bpm: 72,
|
||||
},
|
||||
{
|
||||
id: 'crypt',
|
||||
name: 'Ancient Crypt Shadows',
|
||||
category: 'exploration',
|
||||
icon: <Skull className="w-5 h-5 text-purple-400" />,
|
||||
icon: <Skull className="w-5 h-5 text-indigo-300" />,
|
||||
description: 'Distant water droplets, echoing stone scrapes, and chilling eldritch whispers.',
|
||||
color: 'from-purple-700 to-slate-900',
|
||||
bpm: 60,
|
||||
},
|
||||
{
|
||||
@@ -82,7 +72,6 @@ export const SoundscapeJukeboxModal: React.FC<SoundscapeJukeboxModalProps> = ({
|
||||
category: 'combat',
|
||||
icon: <Swords className="w-5 h-5 text-rose-400" />,
|
||||
description: 'Thundering orchestral war drums, brass fanfares, and roaring dragon flames.',
|
||||
color: 'from-rose-600 to-red-800',
|
||||
bpm: 140,
|
||||
},
|
||||
];
|
||||
@@ -116,210 +105,192 @@ export const SoundscapeJukeboxModal: React.FC<SoundscapeJukeboxModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/80">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl text-white shadow-lg shadow-purple-950/50">
|
||||
<Music className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif tracking-wide text-slate-100">
|
||||
Tactical Jukebox & Ambient Soundscapes
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Immersive multi-layer environmental soundscapes and real-time tabletop soundboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Tactical Jukebox & Ambient Soundscapes"
|
||||
subtitle="Immersive multi-layer environmental soundscapes and real-time tabletop soundboard."
|
||||
icon={<Music className="w-5 h-5" />}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
|
||||
{/* Active Soundscape Player Banner */}
|
||||
<div className="p-4 bg-slate-950 rounded-2xl border border-slate-800 flex items-center justify-between shadow-inner">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg transition transform active:scale-95 cursor-pointer ${
|
||||
isPlaying ? 'bg-amber-600 shadow-amber-950/50' : 'bg-slate-800 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6 fill-white ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-bold text-slate-100">
|
||||
{tracks.find((t) => t.id === activeTrackId)?.name}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-amber-950/80 border border-amber-600/40 text-amber-300 text-[10px] font-mono rounded-full uppercase">
|
||||
{isPlaying ? 'PLAYING NOW' : 'PAUSED'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-mono mt-0.5">
|
||||
Tempo: {tracks.find((t) => t.id === activeTrackId)?.bpm} BPM · Atmospheric Loop
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Volume2 className="w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={masterVolume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setMasterVolume(v);
|
||||
// Live update — no need to pause/resume to change loudness.
|
||||
globalAudio.setAmbienceVolume(v / 100);
|
||||
}}
|
||||
className="w-28 accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs font-mono text-slate-400 w-8 text-right">{masterVolume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Soundscape Presets Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-slate-300 uppercase tracking-wider flex items-center space-x-1.5">
|
||||
<Radio className="w-4 h-4 text-purple-400 animate-pulse" />
|
||||
<span>Select Environmental Preset</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tracks.map((track) => {
|
||||
const isSelected = activeTrackId === track.id;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={() => handleSelectTrack(track.id)}
|
||||
className={`p-3.5 rounded-xl border transition cursor-pointer flex flex-col justify-between ${
|
||||
isSelected
|
||||
? 'bg-slate-850 border-amber-500 shadow-md shadow-amber-950/30'
|
||||
: 'bg-slate-950/70 border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-lg bg-slate-900 border border-slate-800 shadow`}>
|
||||
{track.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-100">{track.name}</div>
|
||||
<div className="text-[10px] font-mono text-slate-400 uppercase">{track.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && <Check className="w-4 h-4 text-amber-400" />}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 mt-2 font-sans">{track.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-Track Audio Faders */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-slate-300 uppercase tracking-wider">
|
||||
Audio Layer Faders
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between text-slate-300 font-semibold">
|
||||
<span>Ambient</span>
|
||||
<span>{ambientVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={ambientVolume}
|
||||
onChange={(e) => setAmbientVolume(Number(e.target.value))}
|
||||
className="w-full accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between text-slate-300 font-semibold">
|
||||
<span>Combat Music</span>
|
||||
<span>{musicVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={musicVolume}
|
||||
onChange={(e) => setMusicVolume(Number(e.target.value))}
|
||||
className="w-full accent-purple-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between text-slate-300 font-semibold">
|
||||
<span>Effects & Spells</span>
|
||||
<span>{fxVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={fxVolume}
|
||||
onChange={(e) => setFxVolume(Number(e.target.value))}
|
||||
className="w-full accent-emerald-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Soundboard 1-Click Cues */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-slate-300 uppercase tracking-wider">
|
||||
Instant Soundboard Cues
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{soundboardCues.map((cue, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={cue.action}
|
||||
className="p-2.5 bg-slate-950 hover:bg-slate-850 border border-slate-800 hover:border-amber-500/50 rounded-xl text-center transition cursor-pointer flex flex-col items-center justify-center space-y-1 group"
|
||||
>
|
||||
<span className="text-lg group-hover:scale-125 transition-transform">{cue.icon}</span>
|
||||
<span className="text-[10px] font-mono font-bold text-slate-300 group-hover:text-amber-300">
|
||||
{cue.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-slate-950/80 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
className="px-5 py-2 bg-[var(--tavern-accent)] hover:bg-[var(--rp-amber-500)] text-[#1c1207] font-bold text-xs font-mono rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
Close Jukebox
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Content Body */}
|
||||
<div className="space-y-6">
|
||||
{/* Active Soundscape Player Banner */}
|
||||
<div className="p-4 bg-[var(--tavern-bg)] rounded-2xl border border-[var(--tavern-border)] flex items-center justify-between shadow-inner">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
aria-label={isPlaying ? 'Pause ambience' : 'Play ambience'}
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center shadow-lg transition transform active:scale-95 cursor-pointer ${
|
||||
isPlaying
|
||||
? 'bg-[var(--tavern-accent)] hover:bg-[var(--rp-amber-500)] text-[#1c1207]'
|
||||
: 'bg-[var(--tavern-surface)] border border-[var(--tavern-border)] text-[#f5ede0]/70'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6 fill-current ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-bold text-[#f5ede0]">
|
||||
{tracks.find((t) => t.id === activeTrackId)?.name}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-[var(--rp-amber-600)]/20 border border-[var(--tavern-accent)]/40 text-amber-300 text-[10px] font-mono rounded-full uppercase">
|
||||
{isPlaying ? 'PLAYING NOW' : 'PAUSED'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[#f5ede0]/70 font-mono mt-0.5">
|
||||
Tempo: {tracks.find((t) => t.id === activeTrackId)?.bpm} BPM · Atmospheric Loop
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Volume2 className="w-4 h-4 text-[#f5ede0]/70" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={masterVolume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setMasterVolume(v);
|
||||
// Live update — no need to pause/resume to change loudness.
|
||||
globalAudio.setAmbienceVolume(v / 100);
|
||||
}}
|
||||
className="w-28 accent-[var(--tavern-accent)] cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs font-mono text-[#f5ede0]/70 w-8 text-right">{masterVolume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Soundscape Presets Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="vtt-engraved text-xs font-mono font-bold uppercase tracking-wider flex items-center space-x-1.5">
|
||||
<Radio className="w-4 h-4 animate-pulse" />
|
||||
<span>Select Environmental Preset</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tracks.map((track) => {
|
||||
const isSelected = activeTrackId === track.id;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={() => handleSelectTrack(track.id)}
|
||||
className={`p-3.5 rounded-xl border transition cursor-pointer flex flex-col justify-between ${
|
||||
isSelected
|
||||
? 'bg-[var(--tavern-surface)] border-[var(--tavern-accent)] shadow-md shadow-black/40'
|
||||
: 'bg-[var(--tavern-bg)]/70 border-[var(--tavern-border)] hover:border-[var(--tavern-accent)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--tavern-bg)] border border-[var(--tavern-border)] shadow">
|
||||
{track.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-[#f5ede0]">{track.name}</div>
|
||||
<div className="text-[10px] font-mono text-[#f5ede0]/60 uppercase">{track.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && <Check className="w-4 h-4 text-[var(--tavern-accent)]" />}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-[#f5ede0]/70 mt-2 font-sans">{track.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-Track Audio Faders */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-[#f5ede0]/70 uppercase tracking-wider">
|
||||
Audio Layer Faders
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-[var(--tavern-bg)] rounded-xl border border-[var(--tavern-border)] space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between text-[#f5ede0] font-semibold">
|
||||
<span>Ambient</span>
|
||||
<span>{ambientVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={ambientVolume}
|
||||
onChange={(e) => setAmbientVolume(Number(e.target.value))}
|
||||
className="w-full accent-[var(--tavern-accent)] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[var(--tavern-bg)] rounded-xl border border-[var(--tavern-border)] space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between text-[#f5ede0] font-semibold">
|
||||
<span>Combat Music</span>
|
||||
<span>{musicVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={musicVolume}
|
||||
onChange={(e) => setMusicVolume(Number(e.target.value))}
|
||||
className="w-full accent-[var(--tavern-accent)] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[var(--tavern-bg)] rounded-xl border border-[var(--tavern-border)] space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between text-[#f5ede0] font-semibold">
|
||||
<span>Effects & Spells</span>
|
||||
<span>{fxVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={fxVolume}
|
||||
onChange={(e) => setFxVolume(Number(e.target.value))}
|
||||
className="w-full accent-emerald-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Soundboard 1-Click Cues */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-mono font-bold text-[#f5ede0]/70 uppercase tracking-wider">
|
||||
Instant Soundboard Cues
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{soundboardCues.map((cue, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={cue.action}
|
||||
className="p-2.5 bg-[var(--tavern-bg)] hover:bg-[var(--tavern-surface)] border border-[var(--tavern-border)] hover:border-[var(--rp-amber-600)] rounded-xl text-center transition cursor-pointer flex flex-col items-center justify-center space-y-1 group"
|
||||
>
|
||||
<span className="text-lg group-hover:scale-125 transition-transform">{cue.icon}</span>
|
||||
<span className="text-[10px] font-mono font-bold text-[#f5ede0]/90 group-hover:text-amber-300">
|
||||
{cue.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Flame,
|
||||
Wand2,
|
||||
Shield,
|
||||
Zap,
|
||||
Clock,
|
||||
Compass,
|
||||
X,
|
||||
Search,
|
||||
Check,
|
||||
Plus,
|
||||
BookOpen,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { Sparkles, Search, BookOpen } from 'lucide-react';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface SpellbookModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -39,8 +26,6 @@ export const SpellbookModal: React.FC<SpellbookModalProps> = ({
|
||||
const [castLevel, setCastLevel] = useState<number>(3);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filtered = PREPARED_SPELLS_DATA.filter((s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) || s.school.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
@@ -51,148 +36,128 @@ export const SpellbookModal: React.FC<SpellbookModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-purple-500/40 rounded-2xl max-w-3xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/60">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-purple-950/50 border border-purple-600/40 rounded-xl text-purple-400">
|
||||
<BookOpen className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif tracking-wide text-slate-100">
|
||||
Digital Grimoire & Spell Upcaster
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Prepared Spells: 6 / 9 · Spell Save DC: 15 · Spell Attack Bonus: +7
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Digital Grimoire & Spell Upcaster"
|
||||
subtitle="Prepared Spells: 6 / 9 · Spell Save DC: 15 · Spell Attack Bonus: +7"
|
||||
icon={<BookOpen className="w-5 h-5" />}
|
||||
size="lg"
|
||||
footer={
|
||||
/* Cast Button */
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCast}
|
||||
className="flex items-center space-x-2 px-5 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white text-xs font-bold rounded-lg shadow-lg shadow-purple-950/60 transition active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Cast at Level {castLevel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body: Left Spell List (5 cols) + Right Spell Details & Upcaster (7 cols) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 flex-1 overflow-hidden">
|
||||
{/* Left Column: Prepared Spell Selector */}
|
||||
<div className="md:col-span-5 border-r border-slate-800 p-4 space-y-3 overflow-y-auto max-h-[60vh]">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search grimoire..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg pl-8 pr-2.5 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{filtered.map((spell) => {
|
||||
const isSelected = selectedSpell.id === spell.id;
|
||||
return (
|
||||
<div
|
||||
key={spell.id}
|
||||
onClick={() => {
|
||||
setSelectedSpell(spell);
|
||||
setCastLevel(spell.level);
|
||||
}}
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer flex items-center justify-between ${
|
||||
isSelected
|
||||
? 'bg-purple-950/60 border-purple-500 text-purple-200'
|
||||
: 'bg-slate-950/40 border-slate-800 hover:bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-bold">{spell.name}</div>
|
||||
<div className="text-[10px] text-slate-400">
|
||||
Level {spell.level} · {spell.school}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 bg-slate-900 border border-slate-700 rounded text-slate-300">
|
||||
Lvl {spell.level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Content Body: Left Spell List (5 cols) + Right Spell Details & Upcaster (7 cols) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12">
|
||||
{/* Left Column: Prepared Spell Selector */}
|
||||
<div className="md:col-span-5 border-r border-slate-800 pr-4 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search grimoire..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg pl-8 pr-2.5 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Statblock & Upcasting Engine */}
|
||||
<div className="md:col-span-7 p-6 space-y-4 overflow-y-auto max-h-[60vh] flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
|
||||
<h3 className="text-lg font-bold font-serif text-slate-100">{selectedSpell.name}</h3>
|
||||
<span className="px-2 py-0.5 bg-purple-950 border border-purple-600/50 text-purple-300 text-xs font-mono font-bold rounded">
|
||||
Level {selectedSpell.level} {selectedSpell.school}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] font-mono text-slate-300 bg-slate-950 p-2.5 rounded-lg border border-slate-800">
|
||||
<div><strong>Casting:</strong> {selectedSpell.time}</div>
|
||||
<div><strong>Range:</strong> {selectedSpell.range}</div>
|
||||
<div><strong>Duration:</strong> {selectedSpell.duration}</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-sans italic bg-slate-950/60 p-3 rounded-lg border border-slate-800/80">
|
||||
"{selectedSpell.desc}"
|
||||
</p>
|
||||
|
||||
{/* Upcasting Slot Selector */}
|
||||
<div className="p-3 bg-purple-950/30 border border-purple-700/40 rounded-xl space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-purple-300 font-mono">
|
||||
<span>Upcast Spell Level:</span>
|
||||
<span className="text-sm text-amber-400">Level {castLevel} Slot</span>
|
||||
<div className="space-y-1.5">
|
||||
{filtered.map((spell) => {
|
||||
const isSelected = selectedSpell.id === spell.id;
|
||||
return (
|
||||
<div
|
||||
key={spell.id}
|
||||
onClick={() => {
|
||||
setSelectedSpell(spell);
|
||||
setCastLevel(spell.level);
|
||||
}}
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer flex items-center justify-between ${
|
||||
isSelected
|
||||
? 'bg-purple-950/60 border-purple-500 text-purple-200'
|
||||
: 'bg-slate-950/40 border-slate-800 hover:bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-bold">{spell.name}</div>
|
||||
<div className="text-[10px] text-slate-400">
|
||||
Level {spell.level} · {spell.school}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 bg-slate-900 border border-slate-700 rounded text-slate-300">
|
||||
Lvl {spell.level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{[selectedSpell.level, selectedSpell.level + 1, selectedSpell.level + 2, selectedSpell.level + 3].filter((l) => l <= 9).map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => setCastLevel(lvl)}
|
||||
className={`flex-1 py-1.5 rounded-lg font-mono text-xs font-bold transition cursor-pointer ${
|
||||
castLevel === lvl
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-900/50 border border-purple-400'
|
||||
: 'bg-slate-900 border border-slate-700 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Lvl {lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-400 font-mono pt-1">
|
||||
<strong className="text-purple-300">Upcast Benefit:</strong> {selectedSpell.upcastDamage}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right Column: Statblock & Upcasting Engine */}
|
||||
<div className="md:col-span-7 pl-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
|
||||
<h3 className="text-lg font-bold font-serif text-slate-100">{selectedSpell.name}</h3>
|
||||
<span className="px-2 py-0.5 bg-purple-950 border border-purple-600/50 text-purple-300 text-xs font-mono font-bold rounded">
|
||||
Level {selectedSpell.level} {selectedSpell.school}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cast Button */}
|
||||
<div className="pt-4 border-t border-slate-800 flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-lg cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCast}
|
||||
className="flex items-center space-x-2 px-5 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white text-xs font-bold rounded-lg shadow-lg shadow-purple-950/60 transition active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Cast at Level {castLevel}</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] font-mono text-slate-300 bg-slate-950 p-2.5 rounded-lg border border-slate-800">
|
||||
<div><strong>Casting:</strong> {selectedSpell.time}</div>
|
||||
<div><strong>Range:</strong> {selectedSpell.range}</div>
|
||||
<div><strong>Duration:</strong> {selectedSpell.duration}</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-sans italic bg-slate-950/60 p-3 rounded-lg border border-slate-800/80">
|
||||
"{selectedSpell.desc}"
|
||||
</p>
|
||||
|
||||
{/* Upcasting Slot Selector */}
|
||||
<div className="p-3 bg-purple-950/30 border border-purple-700/40 rounded-xl space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-purple-300 font-mono">
|
||||
<span>Upcast Spell Level:</span>
|
||||
<span className="text-sm text-amber-400">Level {castLevel} Slot</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{[selectedSpell.level, selectedSpell.level + 1, selectedSpell.level + 2, selectedSpell.level + 3].filter((l) => l <= 9).map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => setCastLevel(lvl)}
|
||||
className={`flex-1 py-1.5 rounded-lg font-mono text-xs font-bold transition cursor-pointer ${
|
||||
castLevel === lvl
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-900/50 border border-purple-400'
|
||||
: 'bg-slate-900 border border-slate-700 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Lvl {lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-400 font-mono pt-1">
|
||||
<strong className="text-purple-300">Upcast Benefit:</strong> {selectedSpell.upcastDamage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useFocusTrap } from './ui/useFocusTrap';
|
||||
import {
|
||||
Video,
|
||||
Radio,
|
||||
@@ -49,8 +50,14 @@ export const StreamerHUDModal: React.FC<StreamerHUDModalProps> = ({
|
||||
setTimeout(() => setTestSent(false), 2500);
|
||||
};
|
||||
|
||||
// Shared dialog behavior (ESC dismiss + focus trap) without restructuring
|
||||
// this bespoke overlay onto ModalShell — same pattern, ladder z-index.
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFocusTrap({ active: true, containerRef, onEscape: onClose });
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md flex items-center justify-center p-4" style={{ zIndex: 'var(--z-modal)' }}>
|
||||
<div ref={containerRef} tabIndex={-1} role="dialog" aria-modal="true">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col font-sans animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/80">
|
||||
@@ -174,6 +181,7 @@ export const StreamerHUDModal: React.FC<StreamerHUDModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Crown,
|
||||
Shield,
|
||||
CreditCard,
|
||||
HardDrive,
|
||||
Users,
|
||||
CheckCircle,
|
||||
X,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
import { Crown, CreditCard, HardDrive, CheckCircle } from 'lucide-react';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface SubscriptionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -18,33 +8,34 @@ interface SubscriptionModalProps {
|
||||
}
|
||||
|
||||
export const SubscriptionModal: React.FC<SubscriptionModalProps> = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-amber-500/40 rounded-2xl max-w-lg w-full p-6 shadow-2xl space-y-5 animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-amber-500 to-amber-700 rounded-xl text-slate-950 shadow-lg shadow-amber-950/50 font-bold">
|
||||
<Crown className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold font-serif text-slate-100">SaaS Account & Subscription</h2>
|
||||
<p className="text-xs text-slate-400">Manage your subscription plan, asset storage, and billing.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="SaaS Account & Subscription"
|
||||
subtitle="Manage your subscription plan, asset storage, and billing."
|
||||
icon={<Crown className="w-5 h-5" />}
|
||||
size="md"
|
||||
footer={
|
||||
/* Footer Actions */
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-1.5 px-4 py-2 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white text-xs font-bold rounded-lg shadow cursor-pointer"
|
||||
>
|
||||
<CreditCard className="w-3.5 h-3.5" />
|
||||
<span>Manage Billing (Stripe)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Current Plan Card */}
|
||||
<div className="bg-slate-950/80 border border-amber-500/40 rounded-xl p-4 shadow-inner space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -87,24 +78,7 @@ export const SubscriptionModal: React.FC<SubscriptionModalProps> = ({ isOpen, on
|
||||
<div className="h-full bg-gradient-to-r from-sky-500 to-amber-500 rounded-full" style={{ width: '1.2%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-lg cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-1.5 px-4 py-2 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white text-xs font-bold rounded-lg shadow cursor-pointer"
|
||||
>
|
||||
<CreditCard className="w-3.5 h-3.5" />
|
||||
<span>Manage Billing (Stripe)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -291,16 +291,22 @@ export const TacticalCanvas: React.FC<TacticalCanvasProps> = ({
|
||||
>
|
||||
{/* Permission Warning Banner */}
|
||||
{permissionWarning && (
|
||||
<div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-rose-950/90 border border-rose-500/80 rounded-xl text-xs font-mono font-bold text-rose-200 shadow-2xl backdrop-blur-md animate-bounce">
|
||||
⚠️ {permissionWarning}
|
||||
</div>
|
||||
<>
|
||||
{/* Toast layer: was z-50 — the same plane as every modal backdrop, so it
|
||||
fought with sheets opened over the canvas. Toast rung sits above all
|
||||
interactive chrome but below modals. */}
|
||||
<div className="absolute top-16 left-1/2 -translate-x-1/2 px-4 py-2 bg-rose-950/90 border border-rose-500/80 rounded-xl text-xs font-mono font-bold text-rose-200 shadow-2xl backdrop-blur-md animate-bounce" style={{ zIndex: 'var(--z-toast)' }}>
|
||||
⚠️ {permissionWarning}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floating Tactical Overlay Controls */}
|
||||
{/* Canvas tool rail. flex-wrap + max-width keep tools inside the canvas
|
||||
area: without them the rail ran under the right character-sheet dock
|
||||
on ≤1440px screens, silently clipping the elevation stepper. */}
|
||||
<div className="absolute top-4 left-4 z-20 flex flex-wrap items-center gap-2 max-w-[calc(100%-2rem)] vtt-glass-panel p-2 rounded-xl text-xs font-mono shadow-2xl border border-slate-800">
|
||||
{/* Tool rail sits at chrome layer: above canvas FX but below any popover/modal. */}
|
||||
<div className="absolute top-4 left-4 flex flex-wrap items-center gap-2 max-w-[calc(100%-2rem)] vtt-glass-panel p-2 rounded-xl text-xs font-mono shadow-2xl" style={{ zIndex: 'var(--z-chrome)' }}>
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1 bg-slate-900/90 p-0.5 rounded-lg border border-slate-800">
|
||||
<button
|
||||
@@ -705,15 +711,18 @@ export const TacticalCanvas: React.FC<TacticalCanvasProps> = ({
|
||||
ref={lightingCanvasRef}
|
||||
width={gridWidth * cellSize}
|
||||
height={gridHeight * cellSize}
|
||||
className="absolute inset-0 pointer-events-none z-20"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: 'var(--z-tokens)' }}
|
||||
/>
|
||||
|
||||
{/* 3D Dice & WebGL Particle FX Overlay Canvas */}
|
||||
{/* 3D Dice & WebGL Particle FX Overlay Canvas — fx rung. Was z-40,
|
||||
which collided with popover dropdowns anchored over the canvas. */}
|
||||
<canvas
|
||||
ref={fxCanvasRef}
|
||||
width={gridWidth * cellSize}
|
||||
height={gridHeight * cellSize}
|
||||
className="absolute inset-0 pointer-events-none z-40"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: 'var(--z-fx)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,15 +11,9 @@ import {
|
||||
Dices,
|
||||
Volume2,
|
||||
Sliders,
|
||||
HardDrive,
|
||||
Check,
|
||||
X,
|
||||
Shield,
|
||||
Crown,
|
||||
Sparkles,
|
||||
Radio,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { ModalShell } from './ui/ModalShell';
|
||||
|
||||
interface UserSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -42,8 +36,6 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
|
||||
const [voiceMode, setVoiceMode] = useState<'ptt' | 'vad'>('vad');
|
||||
const [savedSuccess, setSavedSuccess] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdateUser({
|
||||
...currentUser,
|
||||
@@ -67,40 +59,34 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/85 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden shadow-2xl flex flex-col animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/70">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-slate-800 rounded-xl text-slate-300">
|
||||
<Settings className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold font-serif tracking-wide text-slate-100">
|
||||
User Preferences & Profile
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2 text-xs font-mono mt-0.5">
|
||||
<span className="text-amber-400 font-bold">{currentUser.displayName}</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="px-1.5 py-0.2 bg-slate-800 text-slate-300 rounded uppercase text-[10px]">
|
||||
{currentUser.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="User Preferences & Profile"
|
||||
subtitle={`Signed in as ${currentUser.displayName} · ${String(currentUser.role).toUpperCase()}`}
|
||||
icon={<Settings className="w-5 h-5" />}
|
||||
size="lg"
|
||||
footer={
|
||||
/* Footer Actions */
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
autoFocus // move keyboard focus into the dialog on open
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center space-x-1.5 px-5 py-2 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white text-xs font-bold rounded-lg shadow-lg shadow-amber-950/60 transition cursor-pointer"
|
||||
>
|
||||
{savedSuccess ? <Check className="w-4 h-4 text-emerald-300" /> : null}
|
||||
<span>{savedSuccess ? 'Saved Preferences!' : 'Save Changes'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body with Left Nav Tabs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 flex-1 overflow-hidden">
|
||||
}
|
||||
>
|
||||
{/* Content Body with Left Nav Tabs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12">
|
||||
{/* Left Navigation */}
|
||||
<div className="md:col-span-4 border-r border-slate-800 p-3 space-y-1 bg-slate-950/40">
|
||||
<button
|
||||
@@ -288,24 +274,6 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 bg-slate-950/80 border-t border-slate-800 flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-lg cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center space-x-1.5 px-5 py-2 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white text-xs font-bold rounded-lg shadow-lg shadow-amber-950/60 transition cursor-pointer"
|
||||
>
|
||||
{savedSuccess ? <Check className="w-4 h-4 text-emerald-300" /> : null}
|
||||
<span>{savedSuccess ? 'Saved Preferences!' : 'Save Changes'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useId, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useFocusTrap } from './useFocusTrap';
|
||||
|
||||
/**
|
||||
* Shared modal sheet — the single implementation of the ARIA dialog pattern
|
||||
* for the whole app: ESC dismiss, focus trap + focus restore, backdrop click,
|
||||
* labelled dialog semantics, and a ladder-assigned z-index (never a raw z-50;
|
||||
* see --z-* tokens in index.css). Migrating a hand-rolled modal is mechanical:
|
||||
* delete its outer overlay/panel divs and render <ModalShell> around content.
|
||||
*/
|
||||
|
||||
const SIZES: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
};
|
||||
|
||||
export interface ModalShellProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: ReactNode;
|
||||
/** Panel width preset. */
|
||||
size?: keyof typeof SIZES;
|
||||
/**
|
||||
* Surface treatment: 'tavern' = dark wood/iron chrome panel,
|
||||
* 'parchment' = in-world paper document (uses .vtt-parchment).
|
||||
*/
|
||||
tone?: 'tavern' | 'parchment';
|
||||
footer?: ReactNode;
|
||||
/** Set false for sheets that must not close on stray backdrop clicks. */
|
||||
closeOnBackdrop?: boolean;
|
||||
/** True for modals opened on top of another modal (--z-modal-nested). */
|
||||
nested?: boolean;
|
||||
/** Move initial keyboard focus to a specific control (e.g. a text input). */
|
||||
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ModalShell({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
size = 'md',
|
||||
tone = 'tavern',
|
||||
footer,
|
||||
closeOnBackdrop = true,
|
||||
nested = false,
|
||||
initialFocusRef,
|
||||
children,
|
||||
}: ModalShellProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useFocusTrap({ active: isOpen, containerRef, initialFocusRef, onEscape: onClose });
|
||||
|
||||
// Lock page scroll while open — long sheets would otherwise scroll the app
|
||||
// behind the modal instead of their own body.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const surface =
|
||||
tone === 'parchment'
|
||||
? 'vtt-parchment'
|
||||
: 'vtt-glass-panel border rounded-xl';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
style={{ zIndex: nested ? 'var(--z-modal-nested)' : 'var(--z-modal)' }}
|
||||
onMouseDown={(e) => {
|
||||
// mousedown (not click) so dragging out of the panel doesn't dismiss.
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className={`${surface} w-full ${SIZES[size]} max-h-[85vh] flex flex-col shadow-2xl`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Engraved header — Cinzel display face with gold-leaf gradient */}
|
||||
<div className="flex items-start gap-3 px-5 py-4 border-b border-[var(--tavern-border)] shrink-0">
|
||||
{icon && (
|
||||
<span className="mt-0.5 text-[var(--tavern-accent)]" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={titleId} className="vtt-engraved text-lg font-semibold truncate">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-xs opacity-70 mt-0.5 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={`Close ${title}`}
|
||||
className="shrink-0 p-1.5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto vtt-scrollbar px-5 py-4 flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-5 py-3 border-t border-[var(--tavern-border)] shrink-0 bg-black/20">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Focus-trap hook for modal sheets (WCAG 2.4.3 / ARIA dialog pattern).
|
||||
*
|
||||
* While `active`:
|
||||
* - the previously-focused element is remembered and restored on deactivate,
|
||||
* so keyboard users return to the button that opened the sheet;
|
||||
* - initial focus lands on `initialFocusRef.current` if given, else the first
|
||||
* focusable descendant of the container;
|
||||
* - Tab / Shift+Tab cycle strictly within the container subtree — this
|
||||
* scoping is what makes nested sheets (e.g. Shortcuts over Spellbook) safe:
|
||||
* each trap only intercepts keys while its own container is active and
|
||||
* listeners are per-instance, so the topmost trap sees the event first.
|
||||
* - Escape invokes `onEscape` (dismiss) instead of bubbling to the page.
|
||||
*/
|
||||
export function useFocusTrap(options: {
|
||||
active: boolean;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
onEscape?: () => void;
|
||||
}): void {
|
||||
const { active, containerRef, initialFocusRef, onEscape } = options;
|
||||
// Remembered across effect re-runs via a ref so restore works even if the
|
||||
// component re-renders between open and close.
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
// Initial focus: explicit target wins; otherwise first focusable child,
|
||||
// falling back to the container itself (make it tabIndex={-1} for that).
|
||||
const initial =
|
||||
initialFocusRef?.current ??
|
||||
(container.querySelector<HTMLElement>(FOCUSABLE) ?? container);
|
||||
initial?.focus();
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
// Stop propagation so an underlying page handler (or a parent trap)
|
||||
// doesn't also react to one keypress.
|
||||
event.stopPropagation();
|
||||
if (onEscape) {
|
||||
event.preventDefault();
|
||||
onEscape();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const focusables = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (focusables.length === 0) return;
|
||||
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
|
||||
if (event.shiftKey && (current === first || !container.contains(current))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || !container.contains(current))) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
// Restore focus to wherever the user was before the sheet opened.
|
||||
previouslyFocused.current?.focus?.();
|
||||
previouslyFocused.current = null;
|
||||
};
|
||||
}, [active, containerRef, initialFocusRef, onEscape]);
|
||||
}
|
||||
+121
-27
@@ -1,11 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
/* Rustic identity fonts (Cinzel + EB Garamond, SIL OFL 1.1 — see
|
||||
compendium/ATTRIBUTION.md) load via <link> in index.html, not @import,
|
||||
so the fetch starts in parallel with the bundle. */
|
||||
|
||||
/* ── Rustic Tavern & Parchment design tokens ────────────────────────────────
|
||||
Three-layer architecture (primitive → semantic → component) per the
|
||||
design-system skill. Components must reference semantic/component tokens —
|
||||
never raw hex — so a future light "parchment day mode" is a token swap. */
|
||||
:root {
|
||||
/* Primitives: raw palette values */
|
||||
--rp-parchment-100: #f5ede0;
|
||||
--rp-parchment-200: #e8dcc4;
|
||||
--rp-parchment-300: #d9c9a8;
|
||||
--rp-iron-900: #2c241d;
|
||||
--rp-iron-800: #3d312a;
|
||||
--rp-leather-700: #634832;
|
||||
--rp-leather-600: #7a5c42;
|
||||
--rp-amber-500: #d97706;
|
||||
--rp-amber-600: #b45309;
|
||||
--rp-crimson-600: #9f1239;
|
||||
--rp-forest-600: #166534;
|
||||
--rp-ink-900: #1c150e;
|
||||
|
||||
/* Semantic: purpose aliases */
|
||||
--tavern-bg: var(--rp-iron-900); /* app chrome: dark tavern wood/iron */
|
||||
--tavern-surface: var(--rp-iron-800); /* raised panels */
|
||||
--tavern-border: var(--rp-leather-700); /* beveled frame edges */
|
||||
--tavern-accent: var(--rp-amber-500); /* gold leaf highlights */
|
||||
--tavern-accent-deep: var(--rp-amber-600);
|
||||
--parchment-paper: var(--rp-parchment-100); /* in-world documents */
|
||||
--parchment-paper-aged: var(--rp-parchment-200);
|
||||
--parchment-ink: var(--rp-ink-900); /* writing on parchment */
|
||||
--state-danger: var(--rp-crimson-600);
|
||||
--state-success: var(--rp-forest-600);
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Cinzel', 'Plus Jakarta Sans', serif;
|
||||
--font-serif-prose: 'EB Garamond', Georgia, serif;
|
||||
--font-body: 'Inter', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* ── Z-index ladder ────────────────────────────────────────────────────────
|
||||
Single source of stacking truth. Before this ladder every modal sat at
|
||||
z-50 alongside the canvas error banner, and navbar dropdowns collided
|
||||
with modal backdrops. Rule of thumb when adding layers: pick the lowest
|
||||
rung that satisfies the ordering you need, never a raw z-* utility.
|
||||
tokens(10) < fx(20) < chrome(30) < popover(40) < toast(50)
|
||||
< modal(60) < modal-nested(70) < command(80) */
|
||||
--z-tokens: 10; /* selected-token rings inside the canvas stack */
|
||||
--z-fx: 20; /* canvas particle/FX overlay */
|
||||
--z-chrome: 30; /* persistent app chrome: navbar, tool rail, quickbar */
|
||||
--z-popover: 40; /* dropdown menus anchored to chrome */
|
||||
--z-toast: 50; /* transient banners/alerts above everything interactive */
|
||||
--z-modal: 60; /* modal sheets */
|
||||
--z-modal-nested: 70; /* a modal opened from within another modal */
|
||||
--z-command: 80; /* command palette — always on top */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-display: 'Plus Jakarta Sans', -apple-system, sans-serif;
|
||||
--font-body: 'Inter', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
@@ -52,48 +104,90 @@
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Glassmorphism & SaaS Panels */
|
||||
/* Glassmorphism & SaaS Panels — restyled from cold slate to warm tavern wood:
|
||||
translucent iron surface with a leather frame and candle-warm shadow. */
|
||||
.vtt-glass-panel {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
background: color-mix(in srgb, var(--tavern-surface) 88%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(51, 65, 85, 0.6);
|
||||
border: 1px solid var(--tavern-border);
|
||||
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.vtt-card-elevated {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid rgba(51, 65, 85, 0.7);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.vtt-card-elevated:hover {
|
||||
background: rgba(51, 65, 85, 0.6);
|
||||
border-color: rgba(167, 139, 250, 0.4);
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.18);
|
||||
/* Flat raised surface for dense panels (trackers, quickbars) — cheaper than
|
||||
the glass panel (no backdrop blur) where many instances render per frame. */
|
||||
.vtt-surface {
|
||||
background: var(--tavern-surface);
|
||||
border: 1px solid var(--tavern-border);
|
||||
}
|
||||
|
||||
.vtt-glow-border {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 18px rgba(124, 58, 237, 0.4);
|
||||
/* ── Component layer: rustic surfaces ────────────────────────────────────── */
|
||||
|
||||
/* Engraved display header — Cinzel with a subtle gold-leaf gradient. Apply to
|
||||
view titles, modal headers and the navbar brand. */
|
||||
.vtt-engraved {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.04em;
|
||||
background: linear-gradient(180deg, #f7e8c8 0%, var(--rp-amber-500) 55%, var(--rp-amber-600) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Parchment document card — for in-world paper: handouts, letters, quest text.
|
||||
Aged paper base, iron frame, double bevel (light top-left, dark bottom-right)
|
||||
and a faint edge burn to suggest candlelight. Text uses parchment-ink. */
|
||||
.vtt-parchment {
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(255, 252, 240, 0.9), transparent 55%),
|
||||
radial-gradient(ellipse at bottom right, rgba(138, 106, 62, 0.22), transparent 60%),
|
||||
var(--parchment-paper);
|
||||
color: var(--parchment-ink);
|
||||
border: 1px solid var(--rp-leather-700);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 250, 235, 0.65), /* inner light bevel */
|
||||
inset 0 0 24px rgba(120, 90, 50, 0.18), /* aged edge burn */
|
||||
0 6px 18px rgba(0, 0, 0, 0.55); /* lift off the dark table */
|
||||
font-family: var(--font-serif-prose);
|
||||
}
|
||||
|
||||
/* Vintage bronze scrollbar — the app-wide vtt-scrollbar restyled to aged metal. */
|
||||
.vtt-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.vtt-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
background: var(--rp-iron-900);
|
||||
border-left: 1px solid rgba(99, 72, 50, 0.4);
|
||||
}
|
||||
.vtt-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(71, 85, 105, 0.8);
|
||||
background: linear-gradient(180deg, var(--rp-leather-600), var(--rp-leather-700));
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vtt-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.9);
|
||||
background: linear-gradient(180deg, var(--rp-amber-500), var(--rp-amber-600));
|
||||
}
|
||||
|
||||
.vtt-card-elevated {
|
||||
background: color-mix(in srgb, var(--tavern-surface) 60%, transparent);
|
||||
border: 1px solid var(--tavern-border);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.vtt-card-elevated:hover {
|
||||
background: color-mix(in srgb, var(--tavern-surface) 85%, transparent);
|
||||
border-color: var(--tavern-accent);
|
||||
box-shadow: 0 8px 24px rgba(217, 119, 6, 0.18);
|
||||
}
|
||||
|
||||
.vtt-glow-border {
|
||||
border-color: var(--tavern-accent);
|
||||
box-shadow: 0 0 18px rgba(217, 119, 6, 0.35);
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { opacity: 0.95; transform: scale(1.03); filter: drop-shadow(0 0 8px rgba(124, 58, 237, 0.6)); }
|
||||
50% { opacity: 0.5; transform: scale(0.98); filter: drop-shadow(0 0 2px rgba(124, 58, 237, 0.2)); }
|
||||
0%, 100% { opacity: 0.95; transform: scale(1.03); filter: drop-shadow(0 0 8px rgba(217, 119, 6, 0.55)); }
|
||||
50% { opacity: 0.5; transform: scale(0.98); filter: drop-shadow(0 0 2px rgba(217, 119, 6, 0.15)); }
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
|
||||
@@ -2,6 +2,26 @@ export class AudioManager {
|
||||
private ctx: AudioContext | null = null;
|
||||
public isMuted: boolean = false;
|
||||
|
||||
// ── SFX debounce ──────────────────────────────────────────────────────────
|
||||
// Rapid-fire events (area attacks, multi-token moves, macro bursts) used to
|
||||
// stack identical cues into a harsh click wall. Each cue name is throttled
|
||||
// to one sound per SFX_DEBOUNCE_MS window; extra triggers within the window
|
||||
// are dropped, not queued — the ear reads one solid hit, not a machine gun.
|
||||
private static readonly SFX_DEBOUNCE_MS = 60;
|
||||
private lastPlayedAt: Record<string, number> = {};
|
||||
|
||||
// INVARIANT: every one-shot SFX method in this class MUST gate on
|
||||
// shouldPlay('<method-name>') before touching the AudioContext. A new cue
|
||||
// added without the gate reintroduces the click-wall under burst load.
|
||||
|
||||
private shouldPlay(cue: string): boolean {
|
||||
const now = performance.now();
|
||||
const last = this.lastPlayedAt[cue] ?? -Infinity;
|
||||
if (now - last < AudioManager.SFX_DEBOUNCE_MS) return false;
|
||||
this.lastPlayedAt[cue] = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
private init() {
|
||||
if (!this.ctx && typeof window !== 'undefined') {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
@@ -16,6 +36,7 @@ export class AudioManager {
|
||||
|
||||
public playDiceRoll() {
|
||||
if (this.isMuted) return;
|
||||
if (!this.shouldPlay('playDiceRoll')) return; // debounce rapid duplicate triggers
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
@@ -42,6 +63,7 @@ export class AudioManager {
|
||||
|
||||
public playWeaponImpact() {
|
||||
if (this.isMuted) return;
|
||||
if (!this.shouldPlay('playWeaponImpact')) return; // debounce rapid duplicate triggers
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
@@ -65,6 +87,7 @@ export class AudioManager {
|
||||
|
||||
public playSpellCast() {
|
||||
if (this.isMuted) return;
|
||||
if (!this.shouldPlay('playSpellCast')) return; // debounce rapid duplicate triggers
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
@@ -96,6 +119,7 @@ export class AudioManager {
|
||||
|
||||
public playTurnAdvance() {
|
||||
if (this.isMuted) return;
|
||||
if (!this.shouldPlay('playTurnAdvance')) return; // debounce rapid duplicate triggers
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* PixiJS v8 2D Canvas Fallback Engine.
|
||||
*/
|
||||
|
||||
export class PixiJs2DCanvas {
|
||||
private canvasElementId: string;
|
||||
|
||||
constructor(canvasElementId: string) {
|
||||
this.canvasElementId = canvasElementId;
|
||||
}
|
||||
|
||||
public initialize2D(): void {
|
||||
console.log(`[PixiJS v8 Canvas] 2D Sprite batching active on element #${this.canvasElementId}`);
|
||||
}
|
||||
|
||||
public updateTokenSprite(tokenId: string, x: number, y: number): void {
|
||||
// Top-down 2D sprite transform update (<16ms)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,11 @@ export class SpatialAudioEngine {
|
||||
return { pan: 0, gain: 1.0, distance };
|
||||
}
|
||||
|
||||
// Stereo Panning: -1.0 (far left) to +1.0 (far right)
|
||||
const pan = Math.max(-1.0, Math.min(1.0, dx / 8.0));
|
||||
// Stereo Panning: -1.0 (far left) to +1.0 (far right). tanh(dx/6) instead
|
||||
// of a hard-clamped dx/8: smooth S-curve saturation that asymptotically
|
||||
// approaches the extremes, so panning never "snaps" to full L/R at a fixed
|
||||
// distance and tokens far off-axis still shift subtly further out.
|
||||
const pan = Math.tanh(dx / 6.0);
|
||||
|
||||
// Inverse Distance Rolloff: 1 / (1 + 0.15 * d)
|
||||
const gain = Math.max(0.08, Math.min(1.0, 1.0 / (1.0 + distance * 0.15)));
|
||||
@@ -82,7 +85,11 @@ export class SpatialAudioEngine {
|
||||
const panner = this.ctx.createStereoPanner ? this.ctx.createStereoPanner() : null;
|
||||
const soundGain = this.ctx.createGain();
|
||||
|
||||
soundGain.gain.setValueAtTime(gain * 0.6, now);
|
||||
// 8 ms linear attack before the decay: starting at full amplitude causes
|
||||
// an audible click (hard waveform onset); ramping up from near-silence
|
||||
// removes it without perceptibly softening the transient.
|
||||
soundGain.gain.setValueAtTime(0.0001, now);
|
||||
soundGain.gain.linearRampToValueAtTime(gain * 0.6, now + 0.008);
|
||||
soundGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
|
||||
|
||||
if (panner) {
|
||||
@@ -115,7 +122,9 @@ export class SpatialAudioEngine {
|
||||
const panner = this.ctx.createStereoPanner ? this.ctx.createStereoPanner() : null;
|
||||
const soundGain = this.ctx.createGain();
|
||||
|
||||
soundGain.gain.setValueAtTime(gain * 0.5, now);
|
||||
// Attack ramp as in playSpatialImpact — kills onset click.
|
||||
soundGain.gain.setValueAtTime(0.0001, now);
|
||||
soundGain.gain.linearRampToValueAtTime(gain * 0.5, now + 0.01);
|
||||
soundGain.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
|
||||
|
||||
if (panner) {
|
||||
@@ -150,7 +159,9 @@ export class SpatialAudioEngine {
|
||||
const panner = this.ctx.createStereoPanner ? this.ctx.createStereoPanner() : null;
|
||||
const soundGain = this.ctx.createGain();
|
||||
|
||||
soundGain.gain.setValueAtTime(gain * 0.5, now);
|
||||
// Attack ramp as in playSpatialImpact — kills onset click.
|
||||
soundGain.gain.setValueAtTime(0.0001, now);
|
||||
soundGain.gain.linearRampToValueAtTime(gain * 0.5, now + 0.008);
|
||||
soundGain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
|
||||
|
||||
if (panner) {
|
||||
@@ -183,7 +194,9 @@ export class SpatialAudioEngine {
|
||||
const panner = this.ctx.createStereoPanner ? this.ctx.createStereoPanner() : null;
|
||||
const soundGain = this.ctx.createGain();
|
||||
|
||||
soundGain.gain.setValueAtTime(gain * 0.35, now);
|
||||
// Shortest cue → shortest attack (4 ms) so the dice rattle stays crisp.
|
||||
soundGain.gain.setValueAtTime(0.0001, now);
|
||||
soundGain.gain.linearRampToValueAtTime(gain * 0.35, now + 0.004);
|
||||
soundGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
if (panner) {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* WebGPU Spatial 3D Canvas with WGSL Compute Shaders for Line-of-Sight & Dynamic Lighting (Phase 2 & Phase 4).
|
||||
*/
|
||||
|
||||
export const WGSL_LOS_COMPUTE_SHADER = `
|
||||
struct VoxelParams {
|
||||
grid_width: u32,
|
||||
grid_height: u32,
|
||||
grid_depth: u32,
|
||||
cell_size_feet: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: VoxelParams;
|
||||
@group(0) @binding(1) var<storage, read> occupancy_grid: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> visibility_mask: array<u32>;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
if (global_id.x >= params.grid_width || global_id.y >= params.grid_height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = global_id.y * params.grid_width + global_id.x;
|
||||
let is_solid = occupancy_grid[index];
|
||||
|
||||
// Amanatides-Woo Voxel Traversal ray test
|
||||
if (is_solid == 1u) {
|
||||
visibility_mask[index] = 0u; // Obscured
|
||||
} else {
|
||||
visibility_mask[index] = 1u; // Visible
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class WebGpuSpatialCanvas {
|
||||
private containerId: string;
|
||||
private isWebGpuSupported: boolean = false;
|
||||
|
||||
constructor(containerId: string) {
|
||||
this.containerId = containerId;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<boolean> {
|
||||
if (typeof navigator !== 'undefined' && (navigator as any).gpu) {
|
||||
this.isWebGpuSupported = true;
|
||||
console.log('[WebGPU Spatial Canvas] Initialized with WGSL Compute Shaders (60 FPS target)');
|
||||
return true;
|
||||
} else {
|
||||
console.log('[WebGPU Spatial Canvas] WebGPU not available in this environment; PixiJS 2D fallback active');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public renderFrame(): void {
|
||||
// 60 FPS Render Loop (< 16 ms)
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,34 @@ by Wizards of the Coast under **Creative Commons Attribution 4.0 International**
|
||||
Per the license, this project attributes the source material to Wizards of the
|
||||
Coast. The markdown conversion repository is used as a parsing convenience only;
|
||||
all rules text remains the property of Wizards of the Coast.
|
||||
|
||||
---
|
||||
|
||||
# Frontend Asset Attribution
|
||||
|
||||
Every ingested visual/audio asset is recorded here with source URL, author,
|
||||
and exact license designation, per the asset-pipeline policy (CC0 / CC-BY /
|
||||
copyleft only).
|
||||
|
||||
## Typography
|
||||
|
||||
| Asset | Used for | Author | Source | License |
|
||||
|-------|----------|--------|--------|---------|
|
||||
| Cinzel (500/600/700) | Engraved display headers (`.vtt-engraved`) | Natanael Gama | https://fonts.google.com/specimen/Cinzel | SIL Open Font License 1.1 |
|
||||
| EB Garamond (400–700, italic) | In-world serif prose (`.vtt-parchment`) | Octavio Pardo, Georg Duffner | https://fonts.google.com/specimen/EB+Garamond | SIL Open Font License 1.1 |
|
||||
|
||||
Both fonts are loaded from Google Fonts at runtime (`client/src/index.css`);
|
||||
the OFL permits bundling and redistribution with attribution retained here.
|
||||
|
||||
## Icons
|
||||
|
||||
| Asset | Used for | Author | Source | License |
|
||||
|-------|----------|--------|--------|---------|
|
||||
| lucide-react | All UI iconography | Lucide Contributors | https://lucide.dev | ISC License |
|
||||
|
||||
## Audio
|
||||
|
||||
All sound effects and ambience loops are **synthesized at runtime** via the
|
||||
Web Audio API (`client/src/render/audio_manager.ts` — oscillators, filtered
|
||||
noise buffers). No third-party audio files are ingested, so no external audio
|
||||
licenses apply.
|
||||
@@ -29,7 +29,8 @@ pub use modifier_graph::{
|
||||
ArmorClassCalculator, ModifierPriority, MulticlassSpellSlotMatrix, SpellcastingStats,
|
||||
};
|
||||
pub use rules::{
|
||||
AttackRollResult, MonsterArchetype, RulesEvaluator, SavingThrowResult, SpellDefinition,
|
||||
AttackRollResult, ConcentrationBreakResult, MonsterArchetype, RulesEvaluator,
|
||||
SavingThrowResult, SpellDefinition,
|
||||
};
|
||||
pub use state::{EntityState, GameSession, InitiativeCombatState};
|
||||
pub use state::{ConcentrationState, EntityState, GameSession, InitiativeCombatState};
|
||||
pub use types::*;
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::actions::ActionResolver;
|
||||
use crate::dice::DiceEngine;
|
||||
use crate::state::EntityState;
|
||||
use crate::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -99,9 +101,109 @@ pub struct SavingThrowResult {
|
||||
pub target_is_dead: bool,
|
||||
}
|
||||
|
||||
/// Outcome of a concentration saving throw triggered by damage.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ConcentrationBreakResult {
|
||||
pub dc: i32,
|
||||
pub total: i32,
|
||||
pub maintained: bool,
|
||||
}
|
||||
|
||||
pub struct RulesEvaluator;
|
||||
|
||||
impl RulesEvaluator {
|
||||
/// Derives (advantage, disadvantage) for an attack from entity conditions and elevation.
|
||||
///
|
||||
/// Reuses the existing condition helper flags on `Condition` (see types.rs):
|
||||
/// - Attacker-side conditions that impose disadvantage on attacks (blinded,
|
||||
/// frightened, poisoned, prone, restrained, exhaustion level 3+).
|
||||
/// - Target conditions that grant advantage to the attacker (paralyzed, restrained,
|
||||
/// unconscious; prone only within 5 ft).
|
||||
/// - Target conditions that impose disadvantage on the attacker (invisible;
|
||||
/// prone beyond 5 ft).
|
||||
/// - High ground grants advantage when the existing high-ground attack bonus is > 0.
|
||||
///
|
||||
/// NOTE: when both flags are true the pair CANCELS per SRD 5.1 and resolves as a
|
||||
/// single straight d20 — that cancellation already happens in
|
||||
/// `RulesEvaluator::resolve_attack` (rules.rs), so callers may pass both flags
|
||||
/// through without pre-resolving them here.
|
||||
pub fn edge_from_conditions(
|
||||
attacker: &EntityState,
|
||||
target: &EntityState,
|
||||
distance_feet: f32,
|
||||
attacker_z: f32,
|
||||
target_z: f32,
|
||||
) -> (bool, bool) {
|
||||
let mut advantage = target
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|c| c.grants_advantage_to_attacker(distance_feet));
|
||||
let disadvantage = attacker
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|c| c.inflicts_disadvantage_on_attacks())
|
||||
|| target
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|c| c.inflicts_disadvantage_on_attacker(distance_feet));
|
||||
|
||||
// High ground: only treat as advantage when the bonus is actually applied.
|
||||
if Self::calculate_high_ground_attack_bonus(attacker_z, target_z) > 0 {
|
||||
advantage = true;
|
||||
}
|
||||
|
||||
(advantage, disadvantage)
|
||||
}
|
||||
|
||||
/// Starts concentration on a spell, overwriting any prior concentration
|
||||
/// (SRD replacement rule: casting a new concentration spell ends the old one).
|
||||
pub fn begin_concentration(entity: &mut EntityState, spell_id: &str) {
|
||||
// Overwrite any prior concentration — only one spell can be concentrated on at a time.
|
||||
entity.concentration = Some(crate::state::ConcentrationState {
|
||||
spell_id: spell_id.to_string(),
|
||||
started_round: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Ends concentration. Returns true if there was an active spell to end.
|
||||
pub fn end_concentration(entity: &mut EntityState, _reason: &str) -> bool {
|
||||
entity.concentration.take().is_some()
|
||||
}
|
||||
|
||||
/// Applies damage-triggered concentration save (SRD: CON save vs DC = max(10, damage / 2)).
|
||||
///
|
||||
/// Delegates DC math to `ActionResolver::resolve_concentration_check` so there is a
|
||||
/// single source of truth. Zero damage never triggers a check. On a failed save the
|
||||
/// entity's concentration is cleared.
|
||||
pub fn apply_damage_to_concentration(
|
||||
entity: &mut EntityState,
|
||||
damage_taken: i32,
|
||||
con_roll: i32,
|
||||
con_mod: i32,
|
||||
) -> ConcentrationBreakResult {
|
||||
// No damage taken => no save is triggered; concentration is maintained.
|
||||
if damage_taken <= 0 {
|
||||
return ConcentrationBreakResult {
|
||||
dc: 0,
|
||||
total: con_roll + con_mod,
|
||||
maintained: true,
|
||||
};
|
||||
}
|
||||
|
||||
let (passed, total, dc) =
|
||||
ActionResolver::resolve_concentration_check(con_roll, con_mod, damage_taken);
|
||||
|
||||
if !passed && entity.concentration.is_some() {
|
||||
Self::end_concentration(entity, "FAILED_CONCENTRATION_SAVE");
|
||||
}
|
||||
|
||||
ConcentrationBreakResult {
|
||||
dc,
|
||||
total,
|
||||
maintained: passed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_attack(
|
||||
dice: &mut DiceEngine,
|
||||
attacker_id: Uuid,
|
||||
|
||||
@@ -5,6 +5,13 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Tracks an entity's active concentration (SRD: one spell at a time).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ConcentrationState {
|
||||
pub spell_id: String,
|
||||
pub started_round: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EntityState {
|
||||
pub id: Uuid,
|
||||
@@ -26,6 +33,10 @@ pub struct EntityState {
|
||||
pub is_conscious: bool,
|
||||
pub is_dead: bool,
|
||||
pub is_visible: bool,
|
||||
/// Active concentration spell, if any. Serde default keeps pre-existing
|
||||
/// persisted session / event-log JSON (without this field) deserializing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concentration: Option<ConcentrationState>,
|
||||
}
|
||||
|
||||
impl EntityState {
|
||||
@@ -65,6 +76,7 @@ impl EntityState {
|
||||
is_conscious: true,
|
||||
is_dead: false,
|
||||
is_visible: true,
|
||||
concentration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
use vtt_core::actions::{ActionResolver, AttackOutcome};
|
||||
use vtt_core::dice::DiceEngine;
|
||||
use vtt_core::modifier_graph::{
|
||||
calculate_ability_modifier, calculate_armor_class, calculate_passive_perception,
|
||||
calculate_proficiency_bonus,
|
||||
};
|
||||
use vtt_core::rules::{ConcentrationBreakResult, RulesEvaluator};
|
||||
use vtt_core::state::{ConcentrationState, EntityState};
|
||||
use vtt_core::types::*;
|
||||
|
||||
fn dummy_entity(name: &str) -> EntityState {
|
||||
EntityState::new(
|
||||
uuid::Uuid::new_v4(),
|
||||
format!("compendium_{}", name),
|
||||
name.to_string(),
|
||||
false,
|
||||
20,
|
||||
14,
|
||||
30.0,
|
||||
AbilityScores::default(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_srd_ability_modifier_floored_formula() {
|
||||
assert_eq!(calculate_ability_modifier(1), -5);
|
||||
@@ -191,3 +208,202 @@ fn test_srd_3d_elevation_and_fall_damage() {
|
||||
assert_eq!(vtt_core::rules::RulesEvaluator::calculate_high_ground_attack_bonus(20.0, 0.0), 2);
|
||||
assert_eq!(vtt_core::rules::RulesEvaluator::calculate_high_ground_attack_bonus(5.0, 0.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_srd_concentration_state_machine() {
|
||||
let mut caster = dummy_entity("wizard");
|
||||
assert!(caster.concentration.is_none());
|
||||
|
||||
// Begin concentration
|
||||
RulesEvaluator::begin_concentration(&mut caster, "spell_haste");
|
||||
let conc = caster.concentration.as_ref().expect("concentration active");
|
||||
// started_round defaults to 0 (rounds are not tracked at this layer)
|
||||
assert_eq!(conc.started_round, 0);
|
||||
|
||||
// Replacement rule: casting a second concentration spell overwrites the first
|
||||
RulesEvaluator::begin_concentration(&mut caster, "spell_fly");
|
||||
let conc = caster.concentration.as_ref().expect("still concentrating");
|
||||
assert_eq!(conc.spell_id, "spell_fly");
|
||||
|
||||
// Zero damage => no check is triggered (maintained, DC reported as 0)
|
||||
let noop = RulesEvaluator::apply_damage_to_concentration(&mut caster, 0, 5, -1);
|
||||
assert_eq!(
|
||||
noop,
|
||||
ConcentrationBreakResult { dc: 0, total: 4, maintained: true }
|
||||
);
|
||||
assert_eq!(
|
||||
caster.concentration.as_ref().expect("kept").spell_id,
|
||||
"spell_fly"
|
||||
);
|
||||
|
||||
// DC floor: 12 damage would scale to DC 6 but the floor is DC 10 -> failed save breaks it
|
||||
let broken = RulesEvaluator::apply_damage_to_concentration(&mut caster, 12, 8, 1);
|
||||
assert_eq!(broken.dc, 10);
|
||||
assert_eq!(broken.total, 9);
|
||||
assert!(!broken.maintained);
|
||||
assert!(caster.concentration.is_none());
|
||||
|
||||
// Re-concentrate; damage-scaled DC above floor: 50 damage -> DC 25 -> passed save keeps it
|
||||
RulesEvaluator::begin_concentration(&mut caster, "spell_web");
|
||||
let held = RulesEvaluator::apply_damage_to_concentration(&mut caster, 50, 20, 5);
|
||||
assert_eq!(held.dc, 25);
|
||||
assert_eq!(held.total, 25);
|
||||
assert!(held.maintained);
|
||||
assert_eq!(
|
||||
caster.concentration.as_ref().expect("kept").spell_id,
|
||||
"spell_web"
|
||||
);
|
||||
|
||||
// Voluntary end reports whether a spell was actually dropped
|
||||
assert!(RulesEvaluator::end_concentration(&mut caster, "SPELL_ENDED"));
|
||||
assert!(caster.concentration.is_none());
|
||||
assert!(!RulesEvaluator::end_concentration(&mut caster, "SPELL_ENDED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_srd_edge_from_conditions_cancellation() {
|
||||
// Blinded attacker vs restrained target: attacker's own blindness imposes
|
||||
// disadvantage while the target grants advantage — both flags set, and per SRD
|
||||
// they cancel to a straight d20 inside resolve_attack.
|
||||
let mut attacker = dummy_entity("blinded_archer");
|
||||
attacker.conditions.push(Condition::Blinded);
|
||||
let mut restrained_target = dummy_entity("restrained_ogre");
|
||||
restrained_target.conditions.push(Condition::Restrained);
|
||||
|
||||
let (adv, dis) =
|
||||
RulesEvaluator::edge_from_conditions(&attacker, &restrained_target, 30.0, 0.0, 0.0);
|
||||
assert!(adv && dis, "expected both flags true for cancelling pair");
|
||||
|
||||
// Blinded attacker vs prone target BEYOND 5 ft: prone grants no advantage at range,
|
||||
// so this is pure disadvantage (no cancellation).
|
||||
let mut prone_target = dummy_entity("prone_goblin");
|
||||
prone_target.conditions.push(Condition::Prone);
|
||||
let (adv_far, dis_far) =
|
||||
RulesEvaluator::edge_from_conditions(&attacker, &prone_target, 30.0, 0.0, 0.0);
|
||||
assert!(!adv_far && dis_far);
|
||||
|
||||
// Prone target within 5 ft: melee attackers get pure advantage, no disadvantage.
|
||||
let clean_attacker = dummy_entity("barbarian");
|
||||
let (adv_close, dis_close) =
|
||||
RulesEvaluator::edge_from_conditions(&clean_attacker, &prone_target, 5.0, 0.0, 0.0);
|
||||
assert!(adv_close && !dis_close);
|
||||
|
||||
// Exhaustion level 3+ on the attacker imposes disadvantage on its own attacks.
|
||||
let mut exhausted = dummy_entity("exhausted_ranger");
|
||||
exhausted.conditions.push(Condition::Exhaustion(3));
|
||||
let fresh_target = dummy_entity("orc");
|
||||
let (adv_ex, dis_ex) = RulesEvaluator::edge_from_conditions(&exhausted, &fresh_target, 30.0, 0.0, 0.0);
|
||||
assert!(!adv_ex && dis_ex);
|
||||
|
||||
// High ground only counts when the existing bonus is > 0 (+2 requires >= 10 ft).
|
||||
let (adv_high, dis_high) =
|
||||
RulesEvaluator::edge_from_conditions(&clean_attacker, &fresh_target, 60.0, 15.0, 0.0);
|
||||
assert!(adv_high && !dis_high);
|
||||
let (adv_low, dis_low) =
|
||||
RulesEvaluator::edge_from_conditions(&clean_attacker, &fresh_target, 60.0, 5.0, 0.0);
|
||||
assert!(!adv_low && !dis_low);
|
||||
|
||||
// Cancellation semantics in resolve_attack: adv + dis resolves as a single straight d20,
|
||||
// identical to a plain roll from an identically-seeded engine.
|
||||
const SEED: u64 = 90210;
|
||||
let mut straight = DiceEngine::with_seed(SEED);
|
||||
let expected = straight.roll_d20();
|
||||
|
||||
let mut cancelled = DiceEngine::with_seed(SEED);
|
||||
let result = RulesEvaluator::resolve_attack(
|
||||
&mut cancelled,
|
||||
uuid::Uuid::new_v4(),
|
||||
uuid::Uuid::new_v4(),
|
||||
5,
|
||||
13,
|
||||
"1d8+3",
|
||||
DamageType::Piercing,
|
||||
20,
|
||||
20,
|
||||
0,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
true, // advantage
|
||||
true, // disadvantage — cancels to straight roll
|
||||
)
|
||||
.expect("attack resolves");
|
||||
assert_eq!(result.natural_roll, expected);
|
||||
|
||||
// Sanity check that a real edge is applied: advantage-only takes max of two d20s
|
||||
// from an identically-seeded engine.
|
||||
let mut pair = DiceEngine::with_seed(SEED);
|
||||
let r1 = pair.roll_d20();
|
||||
let r2 = pair.roll_d20();
|
||||
let mut adv_engine = DiceEngine::with_seed(SEED);
|
||||
let adv_result = RulesEvaluator::resolve_attack(
|
||||
&mut adv_engine,
|
||||
uuid::Uuid::new_v4(),
|
||||
uuid::Uuid::new_v4(),
|
||||
5,
|
||||
13,
|
||||
"1d8+3",
|
||||
DamageType::Piercing,
|
||||
20,
|
||||
20,
|
||||
0,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("attack resolves");
|
||||
assert_eq!(adv_result.natural_roll, r1.max(r2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_srd_concentration_state_serialization_roundtrip() {
|
||||
// New field is skipped when None and deserializes back from legacy JSON without it.
|
||||
let json = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"compendium_id": "goblin",
|
||||
"name": "Goblin",
|
||||
"is_player": false,
|
||||
"current_hp": 7,
|
||||
"max_hp": 7,
|
||||
"temp_hp": 0,
|
||||
"ac": 15,
|
||||
"speed_feet": 30.0,
|
||||
"position": [0.0, 0.0, 0.0],
|
||||
"zone_id": "Zone_Default",
|
||||
"abilities": {
|
||||
"strength": 8, "dexterity": 14, "constitution": 10,
|
||||
"intelligence": 10, "wisdom": 8, "charisma": 8
|
||||
},
|
||||
"conditions": [],
|
||||
"action_budget": {
|
||||
"action": true, "bonus_action": true, "reaction": true,
|
||||
"movement_remaining_feet": 30.0, "free_object_interaction": true
|
||||
},
|
||||
"spell_slots_remaining": {},
|
||||
"inventory": { "items": {} },
|
||||
"is_conscious": true,
|
||||
"is_dead": false,
|
||||
"is_visible": true
|
||||
}"#;
|
||||
let legacy: EntityState = serde_json::from_str(json).expect("legacy JSON deserializes");
|
||||
assert!(legacy.concentration.is_none());
|
||||
assert!(serde_json::to_string(&legacy)
|
||||
.expect("serializes")
|
||||
.contains("\"concentration\"") == false);
|
||||
|
||||
let mut concentrated = legacy.clone();
|
||||
concentrated.concentration = Some(ConcentrationState {
|
||||
spell_id: "spell_hunters_mark".to_string(),
|
||||
started_round: 3,
|
||||
});
|
||||
let serialized = serde_json::to_string(&concentrated).expect("serializes");
|
||||
assert!(serialized.contains("\"concentration\""));
|
||||
let roundtrip: EntityState =
|
||||
serde_json::from_str(&serialized).expect("concentration roundtrips");
|
||||
let conc = roundtrip.concentration.expect("present");
|
||||
assert_eq!(conc.spell_id, "spell_hunters_mark");
|
||||
assert_eq!(conc.started_round, 3);
|
||||
}
|
||||
|
||||
@@ -173,5 +173,122 @@ screenshot review of landing + tabletop, and an audio call-site audit.
|
||||
- Full gate: cargo ✅ · pytest 57/1 ✅ · benchmarks MCR 100 % / HCI 1.0 ✅ ·
|
||||
build ✅.
|
||||
|
||||
---
|
||||
|
||||
## Cycle 5 — 2026-08-23 · Rustic Tavern & Parchment identity + audio engineering
|
||||
|
||||
Skill routing: installed the `ui-ux-pro-max` design-system skill (21K installs)
|
||||
and adopted its **three-layer token architecture** (primitive → semantic →
|
||||
component, CSS variables, no raw hex in components).
|
||||
|
||||
### Design tokens (`client/src/index.css`)
|
||||
- **Primitives**: full rustic palette — parchment 100/200/300, iron 800/900,
|
||||
leather 600/700, amber 500/600, crimson/forest state colors, ink 900.
|
||||
- **Semantics**: `--tavern-*` (dark chrome), `--parchment-*` (in-world paper),
|
||||
`--state-*`; a future light "parchment day mode" is a token swap.
|
||||
- **Components**: `.vtt-engraved` (Cinzel + gold-leaf gradient clip),
|
||||
`.vtt-parchment` (aged paper gradients, iron frame, double bevel, candlelight
|
||||
edge burn, EB Garamond prose), vintage bronze scrollbars (replacing slate).
|
||||
- **Design interpretation**: the app keeps its dark-tavern chrome (iron/leather/
|
||||
amber); parchment is the *paper* of in-world documents — the classic D&D-book
|
||||
pairing of dark binding + warm pages.
|
||||
|
||||
### Typography
|
||||
- **Cinzel** (display) + **EB Garamond** (prose) via Google Fonts — both SIL
|
||||
OFL 1.1, recorded in `compendium/ATTRIBUTION.md` alongside the existing SRD
|
||||
CC-BY-4.0 attribution and the lucide-react ISC license.
|
||||
- Applied: navbar brand (engraved gold wordmark), handout document frame
|
||||
(full parchment treatment, tokenized ink color).
|
||||
|
||||
### Audio engineering (`client/src/render/audio_manager.ts`)
|
||||
- **SFX debouncing**: per-cue 60 ms throttle (`shouldPlay`) on dice/impact/
|
||||
spell/turn cues — rapid-fire triggers (area attacks, macro bursts) no longer
|
||||
stack into a harsh click wall; extras are dropped, not queued.
|
||||
|
||||
### Verification
|
||||
- Browser: brand renders in Cinzel w/ background-clip (verified computed
|
||||
style); handout parchment shows aged paper + bevel + serif prose (screenshot).
|
||||
- Full gate: cargo ✅ · pytest 57/1 ✅ · benchmarks MCR 100 % / HCI 1.0 ✅ ·
|
||||
`npm run build` ✅.
|
||||
|
||||
### Rolled forward
|
||||
- `.vtt-engraved` rollout to all 12+ modal headers; parchment treatment for
|
||||
quest journal entries; 320 px–4K + 50–200 % zoom sweep; WCAG AA contrast
|
||||
audit of token pairs; CC0 token/tile asset ingestion.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Cycle 6 — 2026-08-23 · Modal resilience, z-index ladder & first-class concentration
|
||||
|
||||
### Shared dialog infrastructure (`client/src/components/ui/`)
|
||||
- **`ModalShell.tsx`** — single ARIA-dialog implementation: `role="dialog"` +
|
||||
`aria-modal` + labelled title, ESC dismiss, focus trap with restore-to-trigger,
|
||||
scroll lock, backdrop mousedown-close (drag-out safe), tavern/parchment tones,
|
||||
size presets, `nested` rung for sheets-over-sheets, optional footer slot.
|
||||
- **`useFocusTrap.ts`** — save/restore `document.activeElement`, initial-focus
|
||||
resolution, Tab cycling scoped to the container subtree (safe under nesting:
|
||||
per-instance capture listeners, topmost trap wins), Escape with
|
||||
`stopPropagation` so one keypress can't dismiss two layers.
|
||||
- Migrated **13 sheets** onto ModalShell (Spellbook, UserSettings, HandoutManager
|
||||
tone="parchment", MapLayerEditor, CampaignSave, Subscription, Auth, AudioMixer,
|
||||
SoundscapeJukebox, QuestJournal, Safety, Shortcuts(nested)) + wired the trap
|
||||
hook into the two bespoke overlays that resisted restructuring (StreamerHUD,
|
||||
CompendiumView statblock → extracted as `StatblockOverlay`, nested rung).
|
||||
- CommandPalette keeps its custom keyboard shell but moved to the command rung.
|
||||
|
||||
### Z-index ladder (`--z-*` tokens in index.css)
|
||||
Single stacking truth: `tokens(10) < fx(20) < chrome(30) < popover(40) <
|
||||
toast(50) < modal(60) < modal-nested(70) < command(80)` — no raw `z-50`
|
||||
anywhere. Fixed collisions: canvas error banner (was z-50 = modal plane), navbar
|
||||
dropdowns (were z-50 over modal backdrops; now popover above chrome header),
|
||||
FX canvas (was z-40 = popover plane), MacroQuickbar/tool rail/DiceHistoryPanel
|
||||
(chrome rung).
|
||||
|
||||
### Rustic rollout
|
||||
- Navbar fully converted off slate/violet → tavern tokens (also fixes dead
|
||||
`hover:bg-slate-850` classes — not a real Tailwind color, silently no-op).
|
||||
- InitiativeTracker & MacroQuickbar: purple accents → amber/leather; engraved
|
||||
headers; HP bar track tokenized. `.vtt-glass-panel` / `.vtt-card-elevated` /
|
||||
`.vtt-glow-border` restyled slate→iron+leather+amber (app-wide effect).
|
||||
- Fonts moved from render-blocking CSS `@import` to preconnect+stylesheet in
|
||||
`index.html`; duplicate `--font-display` block removed.
|
||||
|
||||
### Audio engineering (`client/src/render/`)
|
||||
- `spatial_audio.ts`: pan `clamp(dx/8)` → `tanh(dx/6)` smooth saturation (no
|
||||
hard L/R snap at 8 ft); 4–10 ms linear attack ramps on all four cues kill the
|
||||
full-amplitude onset click; invariant comment added in `audio_manager.ts`
|
||||
requiring every new one-shot SFX to gate on `shouldPlay()`.
|
||||
|
||||
### Rules engine (`crates/vtt-core`)
|
||||
- **Concentration** now first-class: `ConcentrationState` on `EntityState`
|
||||
(`#[serde(default)]` — legacy persisted sessions still deserialize);
|
||||
`begin_concentration` (SRD replacement rule), `end_concentration`,
|
||||
`apply_damage_to_concentration` delegating DC math to the existing
|
||||
`ActionResolver::resolve_concentration_check` (single source of truth).
|
||||
- **Edge aggregation**: `RulesEvaluator::edge_from_conditions` derives
|
||||
(advantage, disadvantage) from condition flags + high ground; cancellation
|
||||
stays in `resolve_attack` (both flags ⇒ straight d20, per SRD).
|
||||
- Tests: concentration state machine (replacement/floor/break/maintain/voluntary),
|
||||
edge cancellation matrix incl. seeded-dice straight-roll proof, serde
|
||||
round-trip with legacy payloads. vtt-core integration tests 8 → 11.
|
||||
|
||||
### Housekeeping
|
||||
- Removed unused `pixi.js`, `three`, `@types/three` deps and dead
|
||||
`pixijs_canvas.ts` / `webgpu_spatial_canvas.ts` (zero imports).
|
||||
- Preserved root scratch transcript as `docs/traces-antigravity-roadmap.txt`.
|
||||
|
||||
### Verification
|
||||
- Full gate: cargo test --workspace ✅ · pytest 57/1 ✅ · benchmarks MCR 100 % /
|
||||
HCI 1.0 / AFPR 0 % ✅ · `npm run build` zero TS diagnostics ✅.
|
||||
- Grep-audited: zero raw `fixed inset-0` outside ModalShell/CommandPalette;
|
||||
zero raw z-classes on migrated surfaces.
|
||||
|
||||
### Rolled forward
|
||||
- CompendiumView / CharacterSheet / EncounterBuilderView rustic conversion;
|
||||
StreamerHUDModal full ModalShell migration; parchment quest-journal entries;
|
||||
320 px–4K + 50–200 % zoom sweep; WCAG AA audit of remaining token pairs;
|
||||
CC0 token/tile asset ingestion; vtt-server test coverage.
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user