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:
John SmithandClaude committed 2026-08-23 17:11:19 -04:00
1 parent 013677eb4d
commit ff4d47805d
37 files changed
+5496 -1438

No files matched your search

+12
View File
@@ -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">
-159
View File
@@ -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",
-3
View File
@@ -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"
+178 -201
View File
@@ -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>
);
};
+11 -38
View File
@@ -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>
);
};
+11 -23
View File
@@ -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>
);
};
+2 -1
View File
@@ -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()}
+91 -46
View File
@@ -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>
);
}
+2 -1
View File
@@ -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 */}
+19 -51
View File
@@ -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>
);
};
+22 -22
View File
@@ -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" />
+18 -15
View File
@@ -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"
+31 -69
View File
@@ -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>
);
};
+82 -77
View File
@@ -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>
+16 -47
View File
@@ -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>
);
};
+29 -40
View File
@@ -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>
);
};
+41 -60
View File
@@ -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>
);
};
+183 -212
View File
@@ -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>
);
};
+110 -145
View File
@@ -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>
);
};
+10 -2
View File
@@ -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>
);
};
+25 -51
View File
@@ -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>
);
};
+16 -7
View File
@@ -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>
+25 -57
View File
@@ -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>
);
};
+136
View File
@@ -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>
);
}
+85
View File
@@ -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
View File
@@ -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 {
+24
View File
@@ -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;
-19
View File
@@ -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)
}
}
+19 -6
View File
@@ -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)
}
}
+31
View File
@@ -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.
+3 -2
View File
@@ -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::*;
+102
View File
@@ -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,
+12
View File
@@ -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,
}
}
}
+216
View File
@@ -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);
}
+117
View File
@@ -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