diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..033df5fb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.venv
+__pycache__
diff --git a/generate_data.py b/generate_data.py
new file mode 100644
index 00000000..fdbbfd1a
--- /dev/null
+++ b/generate_data.py
@@ -0,0 +1,45 @@
+import httpx
+from datetime import datetime, timedelta, timezone
+from time import sleep
+from main import TelemetrySnapshot
+
+
+def main() -> None:
+ wait = 10
+ online_time = 24 * 3600 # start at 1 day
+ ew = 0
+ ns = 0
+ while True:
+ snapshot = TelemetrySnapshot(
+ character_name="Test name",
+ char_tag="test_tag",
+ session_id="test_session_id",
+ timestamp=datetime.now(tz=timezone.utc),
+ ew=ew,
+ ns=ns,
+ z=0,
+ kills=0,
+ kills_per_hour="kph_str",
+ onlinetime=str(timedelta(seconds=online_time)),
+ deaths=0,
+ rares_found=0,
+ prismatic_taper_count=0,
+ vt_state="test state",
+ )
+ resp = httpx.post(
+ "http://localhost:8000/position/",
+ data=snapshot.model_dump_json(),
+ headers={
+ "Content-Type": "application/json",
+ "X-PLUGIN-SECRET": "your_shared_secret",
+ },
+ )
+ print(resp)
+ sleep(wait)
+ ew += 0.1
+ ns += 0.1
+ online_time += wait
+
+
+if __name__ == "__main__":
+ main()
diff --git a/static/script.js b/static/script.js
index 0b6b9aff..bfe94b72 100644
--- a/static/script.js
+++ b/static/script.js
@@ -209,6 +209,8 @@ function render(players) {
${p.kills_per_hour}
${p.rares_found}
${p.vt_state}
+ ${p.onlinetime}
+ ${p.deaths}
`;
li.addEventListener('click', () => selectPlayer(p, x, y));
diff --git a/static/style.css b/static/style.css
index 031a41be..b6a7286d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -156,11 +156,12 @@ body {
#playerList li {
display: grid;
grid-template-columns: 1fr auto;
- grid-template-rows: auto auto auto;
+ grid-template-rows: auto auto auto auto;
grid-template-areas:
"name loc"
"kills kph"
- "rares meta";
+ "rares meta"
+ "onlinetime deaths";
gap: 4px 8px;
margin: 6px 0;
padding: 8px 10px;
@@ -178,6 +179,8 @@ body {
.stat.kph { grid-area: kph; }
.stat.rares { grid-area: rares; }
.stat.meta { grid-area: meta; }
+.stat.onlinetime { grid-area: onlinetime; }
+.stat.deaths { grid-area: deaths; }
/* pill styling */
#playerList li .stat {
@@ -198,6 +201,8 @@ body {
background: var(--accent);
color: #111;
}
+.stat.onlinetime::before { content: "🕑 "}
+.stat.deaths::before { content: "💀 "}
/* hover & selected states */
#playerList li:hover { background: var(--card-hov); }
diff --git a/static_ws/dereth.png b/static_ws/dereth.png
deleted file mode 100644
index 4722941c..00000000
Binary files a/static_ws/dereth.png and /dev/null differ
diff --git a/static_ws/favicon.ico b/static_ws/favicon.ico
deleted file mode 100644
index 3be83f80..00000000
Binary files a/static_ws/favicon.ico and /dev/null differ
diff --git a/static_ws/graphs.html b/static_ws/graphs.html
deleted file mode 100644
index 644086dc..00000000
--- a/static_ws/graphs.html
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-
-
- Dereth Tracker – Analytics
-
-
-
-
-
-
-
-
-
Session Analytics
-
-
-
-
-
-
-
-
-
diff --git a/static_ws/index.html b/static_ws/index.html
deleted file mode 100644
index 8f1de30b..00000000
--- a/static_ws/index.html
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
- Dereth Tracker
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-
-
-
-
diff --git a/static_ws/script.js b/static_ws/script.js
deleted file mode 100644
index 576e5418..00000000
--- a/static_ws/script.js
+++ /dev/null
@@ -1,383 +0,0 @@
-/* ---------- DOM references --------------------------------------- */
-const wrap = document.getElementById('mapContainer');
-const group = document.getElementById('mapGroup');
-const img = document.getElementById('map');
-const dots = document.getElementById('dots');
-const trailsContainer = document.getElementById('trails');
-const list = document.getElementById('playerList');
-const btnContainer = document.getElementById('sortButtons');
-const tooltip = document.getElementById('tooltip');
-// Chat UI elements
-const chatOverlay = document.getElementById('chatOverlay');
-const chatTitle = document.getElementById('chatTitle');
-const chatClose = document.getElementById('chatClose');
-const chatMessages = document.getElementById('chatMessages');
-const chatForm = document.getElementById('chatForm');
-const chatInput = document.getElementById('chatInput');
-let chatSocket = null;
-let currentChatName = null;
-
-/* ---------- constants ------------------------------------------- */
-const MAX_Z = 10;
-const FOCUS_ZOOM = 3; // zoom level when you click a name
-const POLL_MS = 2000;
-const MAP_BOUNDS = {
- west : -102.04,
- east : 102.19,
- north: 102.16,
- south: -102.00
-};
-
-/* ---------- sort configuration ---------------------------------- */
-const sortOptions = [
- {
- value: "name",
- label: "Name ↑",
- comparator: (a, b) => a.character_name.localeCompare(b.character_name)
- },
- {
- value: "kph",
- label: "KPH ↓",
- comparator: (a, b) => b.kills_per_hour - a.kills_per_hour
- },
- {
- value: "kills",
- label: "Kills ↓",
- comparator: (a, b) => b.kills - a.kills
- },
- {
- value: "rares",
- label: "Rares ↓",
- comparator: (a, b) => (b.rares_found || 0) - (a.rares_found || 0)
- }
-];
-
-let currentSort = sortOptions[0];
-let currentPlayers = [];
-
-/* ---------- generate segmented buttons -------------------------- */
-sortOptions.forEach(opt => {
- const btn = document.createElement('div');
- btn.className = 'btn';
- btn.textContent = opt.label;
- btn.dataset.value = opt.value;
- if (opt.value === currentSort.value) btn.classList.add('active');
-
- btn.addEventListener('click', () => {
- btnContainer.querySelectorAll('.btn')
- .forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- currentSort = opt;
- renderList();
- });
-
- btnContainer.appendChild(btn);
-});
-
-/* ---------- map & state variables ------------------------------- */
-let imgW = 0, imgH = 0;
-let scale = 1, offX = 0, offY = 0, minScale = 1;
-let dragging = false, sx = 0, sy = 0;
-let selected = "";
-let pollID = null;
-
-/* ---------- utility functions ----------------------------------- */
-const hue = name => {
- let h = 0;
- for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h);
- return `hsl(${Math.abs(h) % 360},72%,50%)`;
-};
-
-const loc = (ns, ew) =>
- `${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} `
- + `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`;
-
-function worldToPx(ew, ns) {
- const x = ((ew - MAP_BOUNDS.west)
- / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
- const y = ((MAP_BOUNDS.north - ns)
- / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
- return { x, y };
-}
-
-const applyTransform = () =>
- group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;
-
-function clampPan() {
- if (!imgW) return;
- const r = wrap.getBoundingClientRect();
- const vw = r.width, vh = r.height;
- const mw = imgW * scale, mh = imgH * scale;
-
- offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX));
- offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY));
-}
-
-function updateView() {
- clampPan();
- applyTransform();
-}
-
-function fitToWindow() {
- const r = wrap.getBoundingClientRect();
- scale = Math.min(r.width / imgW, r.height / imgH);
- minScale = scale;
- updateView();
-}
-
-/* ---------- tooltip handlers ------------------------------------ */
-function showTooltip(evt, p) {
- tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`;
- const r = wrap.getBoundingClientRect();
- tooltip.style.left = `${evt.clientX - r.left + 10}px`;
- tooltip.style.top = `${evt.clientY - r.top + 10}px`;
- tooltip.style.display = 'block';
-}
-function hideTooltip() {
- tooltip.style.display = 'none';
-}
-
-/* ---------- polling and initialization -------------------------- */
-async function pollLive() {
- try {
- const [liveRes, trailsRes] = await Promise.all([
- fetch('/live/'),
- fetch('/trails/?seconds=600'),
- ]);
- const { players } = await liveRes.json();
- const { trails } = await trailsRes.json();
- currentPlayers = players;
- renderTrails(trails);
- renderList();
- } catch (e) {
- console.error('Live or trails fetch failed:', e);
- }
-}
-
-function startPolling() {
- if (pollID !== null) return;
- pollLive();
- pollID = setInterval(pollLive, POLL_MS);
-}
-// -------------------- WebSocket live updates --------------------
-let wsLive = null;
-function startWebSocket() {
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${location.host}/ws/track`;
- wsLive = new WebSocket(wsUrl);
- wsLive.onopen = () => console.log('WS /ws/track connected');
- wsLive.onmessage = evt => {
- try {
- const snap = JSON.parse(evt.data);
- currentPlayers = currentPlayers.filter(p => p.character_name !== snap.character_name)
- .concat(snap);
- renderList();
- } catch (e) {
- console.error('Invalid WS message', e);
- }
- };
- wsLive.onclose = () => console.log('WS /ws/track closed');
- wsLive.onerror = e => console.error('WS error', e);
-}
-
-img.onload = () => {
- imgW = img.naturalWidth;
- imgH = img.naturalHeight;
- // size the SVG trails container to match the map dimensions
- if (trailsContainer) {
- trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`);
- trailsContainer.setAttribute('width', `${imgW}`);
- trailsContainer.setAttribute('height', `${imgH}`);
- }
- fitToWindow();
- startPolling();
- // start live updates via WebSocket
- startWebSocket();
-};
-
-/* ---------- rendering sorted list & dots ------------------------ */
-function renderList() {
- const sorted = [...currentPlayers].sort(currentSort.comparator);
- render(sorted);
-}
-
-function render(players) {
- dots.innerHTML = '';
- list.innerHTML = '';
-
- players.forEach(p => {
- const { x, y } = worldToPx(p.ew, p.ns);
-
- // dot
- const dot = document.createElement('div');
- dot.className = 'dot';
- dot.style.left = `${x}px`;
- dot.style.top = `${y}px`;
- dot.style.background = hue(p.character_name);
-
-
-
-
- // custom tooltip
- dot.addEventListener('mouseenter', e => showTooltip(e, p));
- dot.addEventListener('mousemove', e => showTooltip(e, p));
- dot.addEventListener('mouseleave', hideTooltip);
-
- // click to select/zoom
- dot.addEventListener('click', () => selectPlayer(p, x, y));
-
- if (p.character_name === selected) dot.classList.add('highlight');
- dots.appendChild(dot);
- //sidebar
- const li = document.createElement('li');
- const color = hue(p.character_name);
- li.style.borderLeftColor = color;
- li.className = 'player-item';
- li.innerHTML = `
- ${p.character_name}
- ${loc(p.ns, p.ew)}
- ${p.kills}
- ${p.kills_per_hour}
- ${p.rares_found}
- ${p.vt_state}
- `;
-
- // Add Chat button
- const chatBtn = document.createElement('button');
- chatBtn.textContent = 'Chat';
- chatBtn.className = 'chat-btn';
- chatBtn.addEventListener('click', e => { e.stopPropagation(); openChat(p.character_name); });
- li.appendChild(chatBtn);
- li.addEventListener('click', () => selectPlayer(p, x, y));
- if (p.character_name === selected) li.classList.add('selected');
- list.appendChild(li);
- });
-}
-
-/* ---------- rendering trails ------------------------------- */
-function renderTrails(trailData) {
- trailsContainer.innerHTML = '';
- const byChar = trailData.reduce((acc, pt) => {
- (acc[pt.character_name] = acc[pt.character_name] || []).push(pt);
- return acc;
- }, {});
- for (const [name, pts] of Object.entries(byChar)) {
- if (pts.length < 2) continue;
- const points = pts.map(pt => {
- const { x, y } = worldToPx(pt.ew, pt.ns);
- return `${x},${y}`;
- }).join(' ');
- const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
- poly.setAttribute('points', points);
- poly.setAttribute('stroke', hue(name));
- poly.setAttribute('fill', 'none');
- poly.setAttribute('class', 'trail-path');
- trailsContainer.appendChild(poly);
- }
-}
-/* ---------- selection centering, focus zoom & blink ------------ */
-function selectPlayer(p, x, y) {
- selected = p.character_name;
- // set focus zoom
- scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM));
- // center on the player
- const r = wrap.getBoundingClientRect();
- offX = r.width / 2 - x * scale;
- offY = r.height / 2 - y * scale;
- updateView();
- renderList(); // keep sorted + highlight
-}
-
-/* ---------- pan & zoom handlers -------------------------------- */
-wrap.addEventListener('wheel', e => {
- e.preventDefault();
- if (!imgW) return;
-
- const r = wrap.getBoundingClientRect();
- const mx = (e.clientX - r.left - offX) / scale;
- const my = (e.clientY - r.top - offY) / scale;
- const factor = e.deltaY > 0 ? 0.9 : 1.1;
- let ns = scale * factor;
- ns = Math.max(minScale, Math.min(MAX_Z, ns));
-
- offX -= mx * (ns - scale);
- offY -= my * (ns - scale);
- scale = ns;
- updateView();
-}, { passive: false });
-
-wrap.addEventListener('mousedown', e => {
- dragging = true; sx = e.clientX; sy = e.clientY;
- wrap.classList.add('dragging');
-});
-window.addEventListener('mousemove', e => {
- if (!dragging) return;
- offX += e.clientX - sx; offY += e.clientY - sy;
- sx = e.clientX; sy = e.clientY;
- updateView();
-});
-window.addEventListener('mouseup', () => {
- dragging = false; wrap.classList.remove('dragging');
-});
-
-wrap.addEventListener('touchstart', e => {
- if (e.touches.length !== 1) return;
- dragging = true;
- sx = e.touches[0].clientX; sy = e.touches[0].clientY;
-});
-wrap.addEventListener('touchmove', e => {
- if (!dragging || e.touches.length !== 1) return;
- const t = e.touches[0];
- offX += t.clientX - sx; offY += t.clientY - sy;
- sx = t.clientX; sy = t.clientY;
- updateView();
-});
-wrap.addEventListener('touchend', () => {
- dragging = false;
-});
-// Chat UI functions
-function openChat(name) {
- currentChatName = name;
- chatTitle.textContent = `Chat: ${name}`;
- chatMessages.innerHTML = '';
- chatOverlay.classList.remove('hidden');
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${location.host}/ws/command?character_name=${encodeURIComponent(name)}`;
- chatSocket = new WebSocket(wsUrl);
- chatSocket.onmessage = evt => {
- try {
- const msg = JSON.parse(evt.data);
- appendChatMessage(msg);
- } catch (e) { console.error('Invalid chat msg', e); }
- };
- chatSocket.onopen = () => console.log(`Chat WS connected: ${wsUrl}`);
- chatSocket.onclose = () => console.log('Chat WS closed');
- chatSocket.onerror = e => console.error('Chat WS error', e);
-}
-
-function closeChat() {
- if (chatSocket) { chatSocket.close(); chatSocket = null; }
- chatOverlay.classList.add('hidden');
- currentChatName = null;
-}
-
-function appendChatMessage(msg) {
- const div = document.createElement('div');
- div.className = 'chat-message';
- const time = new Date(msg.timestamp).toLocaleTimeString();
- const who = msg.from === 'browser' ? 'You' : msg.from;
- div.textContent = `[${time}] ${who}: ${msg.text}`;
- chatMessages.appendChild(div);
- chatMessages.scrollTop = chatMessages.scrollHeight;
-}
-
-chatClose.addEventListener('click', closeChat);
-chatForm.addEventListener('submit', e => {
- e.preventDefault();
- if (!chatSocket || !currentChatName) return;
- const text = chatInput.value.trim();
- if (!text) return;
- chatSocket.send(JSON.stringify({ command: text }));
- appendChatMessage({ from: 'browser', text, timestamp: new Date().toISOString() });
- chatInput.value = '';
-});
diff --git a/static_ws/style.css b/static_ws/style.css
deleted file mode 100644
index 5deb8ea6..00000000
--- a/static_ws/style.css
+++ /dev/null
@@ -1,354 +0,0 @@
-:root {
- --sidebar-width: 280px;
- --bg-main: #111;
- --bg-side: #1a1a1a;
- --card: #222;
- --card-hov:#333;
- --text: #eee;
- --accent: #88f;
-}
-.chat-form button { border: none; background: var(--accent); color: #111; padding: 8px 12px; cursor: pointer; }
-
-/* Chat overlay styling */
- .chat-overlay {
- position: absolute;
- top: 10px;
- left: calc(var(--sidebar-width) + 10px);
- width: 300px;
- height: 400px;
- background: rgba(20,20,20,0.95);
- border: 1px solid #444;
- border-radius: 4px;
- display: flex;
- flex-direction: column;
- z-index: 1000;
-}
-.chat-overlay.hidden {
- display: none;
-}
-.chat-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px;
- background: #333;
- color: #eee;
- font-weight: bold;
-}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
- color: #eee;
- font-size: 0.85rem;
-}
-.chat-form {
- display: flex;
- border-top: 1px solid #444;
-}
-.chat-form input {
- flex: 1;
- border: none;
- padding: 8px;
- background: #222;
- color: #eee;
-}
-.chat-form input:focus {
- outline: none;
-}
-.chat-form button {
- border: none;
- background: var(--accent);
- color: #111;
- padding: 8px 12px;
- cursor: pointer;
-}
-.chat-message {
- margin-bottom: 6px;
-}
-.chat-message:nth-child(odd) {
- background: rgba(255,255,255,0.05);
- padding: 4px 6px;
- border-radius: 3px;
-}
-.chat-message:nth-child(even) {
- background: rgba(255,255,255,0.02);
- padding: 4px 6px;
- border-radius: 3px;
-}
-
-html {
- margin: 0;
- height: 100%;
- width: 100%;
-}
-
-body {
- margin: 0;
- height: 100%;
- display: flex;
- overflow: hidden;
- font-family: "Segoe UI", sans-serif;
- background: var(--bg-main);
- color: var(--text);
-}
-
-/* ---------- sort buttons --------------------------------------- */
-.sort-buttons {
- display: flex;
- gap: 4px;
- margin: 12px 16px 8px;
-}
-.sort-buttons .btn {
- flex: 1;
- padding: 6px 8px;
- background: #222;
- color: #eee;
- border: 1px solid #555;
- border-radius: 4px;
- text-align: center;
- cursor: pointer;
- user-select: none;
- font-size: 0.9rem;
-}
-.sort-buttons .btn.active {
- background: var(--accent);
- color: #111;
- border-color: var(--accent);
-}
-
-/* ---------- sidebar --------------------------------------------- */
-#sidebar {
- width: var(--sidebar-width);
- scrollbar-width: none;
- background: var(--bg-side);
- border-right: 2px solid #333;
- box-sizing: border-box;
- padding: 18px 16px;
- overflow-y: auto;
-}
-#sidebar h2 {
- margin: 8px 0 12px;
- font-size: 1.25rem;
- color: var(--accent);
-}
-#playerList {
- list-style: none;
- margin: 0;
- padding: 0;
-}
-#playerList li {
- margin: 4px 0;
- padding: 6px 8px;
- background: var(--card);
- border-left: 4px solid #555;
- cursor: pointer;
-}
-#playerList li:hover {
- background: var(--card-hov);
-}
-#playerList li.selected {
- background: #454545;
-}
-
-/* ---------- map container --------------------------------------- */
-#mapContainer {
- flex: 1;
- min-width: 0;
- min-height: 0;
- position: relative;
- overflow: hidden;
- background: #000;
- cursor: grab;
-}
-#mapContainer.dragging {
- cursor: grabbing;
-}
-#mapGroup {
- position: absolute;
- top: 0;
- left: 0;
- transform-origin: 0 0;
-}
-#map {
- display: block;
- user-select: none;
- pointer-events: none;
-}
-
-/* ---------- dots ------------------------------------------------ */
-#dots {
- position: absolute;
- top: 0;
- left: 0;
- pointer-events: none;
-}
-.dot {
- position: absolute;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- border: 1px solid #000;
- transform: translate(-50%, -50%);
-
- /* enable events on each dot */
- pointer-events: auto;
- cursor: pointer;
-}
-.dot.highlight {
- width: 10px;
- height: 10px;
- animation: blink 0.6s step-end infinite;
-}
-@keyframes blink {
- 50% { opacity: 0; }
-}
-
-/* ---------- tooltip --------------------------------------------- */
-.tooltip {
- position: absolute;
- display: none;
- background: rgba(0, 0, 0, 0.8);
- color: #fff;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
- pointer-events: none;
- white-space: nowrap;
- z-index: 1000;
-}
-/* make each row a flex container */
-/* 2-column flex layout for each player row */
-/* make each row a flex container */
-/* make each row a vertical stack */
-/* make each player row into a 3×2 grid */
-#playerList li {
- display: grid;
- grid-template-columns: 1fr auto;
- grid-template-rows: auto auto auto;
- grid-template-areas:
- "name loc"
- "kills kph"
- "rares meta";
- gap: 4px 8px;
- margin: 6px 0;
- padding: 8px 10px;
- background: var(--card);
- border-left: 4px solid transparent;
- transition: background 0.15s;
- font-size: 0.85rem;
-}
-
-/* assign each span into its grid cell */
-.player-name { grid-area: name; font-weight: 600; color: var(--text); }
-.player-loc { grid-area: loc; font-size: 0.75rem; color: #aaa; }
-
-.stat.kills { grid-area: kills; }
-.stat.kph { grid-area: kph; }
-.stat.rares { grid-area: rares; }
-.stat.meta { grid-area: meta; }
-
-/* pill styling */
-#playerList li .stat {
- background: rgba(255,255,255,0.1);
- padding: 4px 8px;
- border-radius: 12px;
- display: inline-block;
- font-size: 0.75rem;
- white-space: nowrap;
- color: var(--text);
-}
-
-/* icons & suffixes */
-.stat.kills::before { content: "⚔️ "; }
-.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; }
-.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; }
-.stat.meta {
- background: var(--accent);
- color: #111;
-}
-
-/* hover & selected states */
-#playerList li:hover { background: var(--card-hov); }
-#playerList li.selected { background: #454545; }
-/* trails paths */
-#trails {
- position: absolute;
- top: 0;
- left: 0;
- pointer-events: none;
-}
-.trail-path {
- fill: none;
- stroke-width: 2px;
- stroke-opacity: 0.7;
- stroke-linecap: round;
- stroke-linejoin: round;
-}
-/* Chat overlay styling */
-.chat-overlay {
- position: absolute;
- top: 10px;
- right: 10px;
- width: 300px;
- height: 400px;
- background: rgba(20,20,20,0.95);
- border: 1px solid #444;
- border-radius: 4px;
- display: flex;
- flex-direction: column;
- z-index: 1000;
-}
-.chat-overlay.hidden {
- display: none;
-}
-.chat-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px;
- background: #333;
- color: #eee;
- font-weight: bold;
-}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
- color: #eee;
- font-size: 0.85rem;
-}
-.chat-form {
- display: flex;
- border-top: 1px solid #444;
-}
-.chat-form input {
- flex: 1;
- border: none;
- padding: 8px;
- background: #222;
- color: #eee;
-}
-.chat-form input:focus {
- outline: none;
-}
-.chat-form button {
- border: none;
- background: var(--accent);
- color: #111;
- padding: 8px 12px;
- cursor: pointer;
-}
-.chat-message {
- margin-bottom: 6px;
-}
-.chat-message:nth-child(odd) {
- background: rgba(255,255,255,0.05);
- padding: 4px 6px;
- border-radius: 3px;
-}
-.chat-message:nth-child(even) {
- background: rgba(255,255,255,0.02);
- padding: 4px 6px;
- border-radius: 3px;
-}