feat: session modes (Talk/Cash) + live session HUD
Lyra now switches register based on what she's doing at the table instead of
being a wishy-washy companion mid-session.
Modes (lyra/modes.py):
- Talk (default companion) + Cash (live cash copilot); a mode = prompt card +
tool allow-list. Tool gating via tools.specs(allow=).
- Two-register Cash voice: act-first one-line logging when fed facts; full warm
companion voice for strategy / tilt / mental game.
- mode persisted per chat session (new sessions.mode column); auto-switch into
Cash when start_session fires; UI forces cloud backend in Cash (tools only
fire there).
Stack tracking + HUD:
- log_stack tool + poker_stack_log table; live net while sitting (stack - buy-in).
- poker.hud() bundle; /session HUD page (stack sparkline, hands, villains, notes,
stats) polling /session/data every 5s; Talk/Cash switcher + Session nav.
Endpoints: /session, /session/data, GET/POST /sessions/{id}/mode, /modes.
tests/test_modes.py (gating, mode roundtrip, stack/HUD); 36 tests green. v0.3.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lyra import chat, logbus, memory, poker, self_state, summary
|
||||
from lyra import chat, logbus, memory, modes, poker, self_state, summary
|
||||
from lyra.llm import Backend
|
||||
|
||||
|
||||
@@ -85,6 +85,34 @@ def create_app() -> FastAPI:
|
||||
gist = await asyncio.to_thread(summary.summarize_session, session_id)
|
||||
return {"ok": gist is not None, "summary": gist}
|
||||
|
||||
@app.get("/modes")
|
||||
async def list_modes() -> dict:
|
||||
"""Available conversation modes, for the UI switcher."""
|
||||
return {"modes": modes.listing(), "default": modes.DEFAULT}
|
||||
|
||||
@app.get("/sessions/{session_id}/mode")
|
||||
async def get_mode(session_id: str) -> dict:
|
||||
return {"mode": memory.get_session_mode(session_id) or modes.DEFAULT}
|
||||
|
||||
@app.post("/sessions/{session_id}/mode")
|
||||
async def set_mode(session_id: str, request: Request) -> dict:
|
||||
body = await request.json()
|
||||
mode = body.get("mode") or modes.DEFAULT
|
||||
memory.set_session_mode(session_id, mode)
|
||||
logbus.log("info", "mode set", session=session_id, mode=mode)
|
||||
return {"ok": True, "mode": mode}
|
||||
|
||||
@app.get("/session")
|
||||
async def session_hud_page() -> FileResponse:
|
||||
"""Live session HUD — stack, hands, villains, notes for the open session."""
|
||||
return FileResponse(str(_STATIC / "session.html"))
|
||||
|
||||
@app.get("/session/data")
|
||||
async def session_hud_data() -> dict:
|
||||
"""The current live session's HUD bundle (or {session: None} if none open)."""
|
||||
bundle = await asyncio.to_thread(poker.hud)
|
||||
return bundle or {"session": None}
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
@@ -94,6 +122,8 @@ def create_app() -> FastAPI:
|
||||
|
||||
model_override = body.get("model") or None
|
||||
memory.ensure_session(session_id)
|
||||
if body.get("mode"):
|
||||
memory.set_session_mode(session_id, body["mode"])
|
||||
try:
|
||||
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
|
||||
except Exception as exc:
|
||||
@@ -124,6 +154,8 @@ def create_app() -> FastAPI:
|
||||
user_msg = _last_user_message(body.get("messages", []))
|
||||
model_override = body.get("model") or None
|
||||
memory.ensure_session(session_id)
|
||||
if body.get("mode"):
|
||||
memory.set_session_mode(session_id, body["mode"])
|
||||
|
||||
async def gen():
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
+71
-13
@@ -25,8 +25,8 @@
|
||||
<div class="mobile-menu-section">
|
||||
<h4>Mode</h4>
|
||||
<select id="mobileMode">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="cortex">Cortex</option>
|
||||
<option value="conversation">💬 Talk</option>
|
||||
<option value="poker_cash">♠ Cash</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
<div class="mobile-menu-section">
|
||||
<h4>Actions</h4>
|
||||
<button id="mobileSessionBtn">🎬 Session HUD</button>
|
||||
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
|
||||
<button id="mobileFullLogBtn">⛶ Full Log</button>
|
||||
<button id="mobileJournalBtn">📔 Journal</button>
|
||||
@@ -59,10 +60,11 @@
|
||||
</button>
|
||||
<span class="brand">Lyra</span>
|
||||
<span class="brand-dot" id="brandDot" title="Relay status"></span>
|
||||
<button class="mode-badge" id="modeBadge" type="button" title="Tap to toggle Talk / Cash mode">💬 Talk</button>
|
||||
<label for="mode">Mode:</label>
|
||||
<select id="mode">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="cortex">Cortex</option>
|
||||
<option value="conversation">💬 Talk</option>
|
||||
<option value="poker_cash">♠ Cash</option>
|
||||
</select>
|
||||
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
|
||||
<div id="theme-toggle">
|
||||
@@ -78,6 +80,7 @@
|
||||
<button id="renameSessionBtn">✏️ Rename</button>
|
||||
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
|
||||
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
|
||||
<a id="sessionBtn" href="/session" target="_blank" rel="noopener" title="Live session HUD" role="button">🎬 Session</a>
|
||||
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
|
||||
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
|
||||
</div>
|
||||
@@ -118,6 +121,7 @@
|
||||
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
|
||||
<nav id="tabbar" aria-label="Primary navigation">
|
||||
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
|
||||
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
|
||||
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
|
||||
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab" id="moreTab" type="button"><span class="ti">⋯</span><span class="tl">More</span></button>
|
||||
@@ -298,6 +302,10 @@
|
||||
// Which chat backend to use (local Ollama vs cloud OpenAI).
|
||||
let backend = localStorage.getItem("standardModeBackend") || "local";
|
||||
|
||||
// Cash mode is useless without tools, and tools only fire on cloud — so a
|
||||
// live poker session forces the cloud backend regardless of the saved pick.
|
||||
if (mode === "poker_cash") backend = "cloud";
|
||||
|
||||
const body = {
|
||||
mode: mode,
|
||||
messages: history,
|
||||
@@ -376,6 +384,12 @@
|
||||
finalizeAssistantBubble(div, full || "(no reply)");
|
||||
history.push({ role: "assistant", content: full || "(no reply)" });
|
||||
await saveSession();
|
||||
|
||||
// If she opened a session this turn, the server auto-flips to Cash mode —
|
||||
// reflect that here so the badge/HUD follow without a manual switch.
|
||||
if (document.getElementById("mode").value !== "poker_cash") {
|
||||
loadModeFor(currentSession);
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantBubble() {
|
||||
@@ -499,6 +513,44 @@
|
||||
}
|
||||
|
||||
|
||||
// ----- Conversation mode (Talk / Cash) -----
|
||||
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Cash" };
|
||||
|
||||
// Reflect a mode value across the controls + header accent (no network call).
|
||||
function applyMode(value) {
|
||||
if (!MODE_LABELS[value]) value = "conversation";
|
||||
const desk = document.getElementById("mode");
|
||||
const mob = document.getElementById("mobileMode");
|
||||
const badge = document.getElementById("modeBadge");
|
||||
if (desk) desk.value = value;
|
||||
if (mob) mob.value = value;
|
||||
if (badge) badge.textContent = MODE_LABELS[value];
|
||||
document.body.classList.toggle("cash-mode", value === "poker_cash");
|
||||
localStorage.setItem("lyraMode", value);
|
||||
}
|
||||
|
||||
// User picked a mode: apply locally + persist it to this session on the server.
|
||||
async function chooseMode(value) {
|
||||
applyMode(value);
|
||||
if (!currentSession) return;
|
||||
try {
|
||||
await fetch(`${RELAY_BASE}/sessions/${currentSession}/mode`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: value })
|
||||
});
|
||||
} catch (e) { /* non-fatal: the mode still rides along in the chat body */ }
|
||||
}
|
||||
|
||||
// Pull the active mode for a session from the server (fallback: last local choice).
|
||||
async function loadModeFor(sessionId) {
|
||||
let value = localStorage.getItem("lyraMode") || "conversation";
|
||||
try {
|
||||
const r = await fetch(`${RELAY_BASE}/sessions/${sessionId}/mode`);
|
||||
if (r.ok) { const d = await r.json(); if (d.mode) value = d.mode; }
|
||||
} catch (e) { /* keep the local fallback */ }
|
||||
applyMode(value);
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
|
||||
@@ -578,19 +630,20 @@
|
||||
mobileMenuOverlay.addEventListener("click", closeMobileMenu);
|
||||
document.getElementById("moreTab").addEventListener("click", toggleMobileMenu);
|
||||
|
||||
// Sync mobile menu controls with desktop
|
||||
// Mode controls (Talk / Cash): the desktop select, the mobile-menu select,
|
||||
// and the always-visible header badge all funnel through chooseMode.
|
||||
const mobileMode = document.getElementById("mobileMode");
|
||||
const desktopMode = document.getElementById("mode");
|
||||
const modeBadge = document.getElementById("modeBadge");
|
||||
|
||||
// Sync mode selection
|
||||
mobileMode.addEventListener("change", (e) => {
|
||||
desktopMode.value = e.target.value;
|
||||
desktopMode.dispatchEvent(new Event("change"));
|
||||
});
|
||||
desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
|
||||
mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
|
||||
modeBadge.addEventListener("click", () =>
|
||||
chooseMode(desktopMode.value === "poker_cash" ? "conversation" : "poker_cash"));
|
||||
|
||||
desktopMode.addEventListener("change", (e) => {
|
||||
mobileMode.value = e.target.value;
|
||||
});
|
||||
// Reflect the last-used mode immediately; the per-session value loads once
|
||||
// the current session is known (below).
|
||||
applyMode(localStorage.getItem("lyraMode") || "conversation");
|
||||
|
||||
// Mobile theme toggle
|
||||
document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => {
|
||||
@@ -700,6 +753,7 @@
|
||||
// Load current session history
|
||||
if (currentSession) {
|
||||
await loadSession(currentSession);
|
||||
await loadModeFor(currentSession);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -710,6 +764,7 @@
|
||||
localStorage.setItem("currentSession", currentSession);
|
||||
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
|
||||
await loadSession(currentSession);
|
||||
await loadModeFor(currentSession);
|
||||
});
|
||||
|
||||
// Create new session
|
||||
@@ -1023,6 +1078,9 @@
|
||||
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/journal";
|
||||
});
|
||||
document.getElementById("mobileSessionBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/session";
|
||||
});
|
||||
|
||||
// Connect to the global live log on page load.
|
||||
connectThinkingStream();
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Session</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
|
||||
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
|
||||
--good: #8fd694; --mid: #ffb347; --low: #ff6b6b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; min-height: 100%; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 10; background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border); padding: env(safe-area-inset-top) 14px 0;
|
||||
}
|
||||
.topbar { display: flex; align-items: center; gap: 10px; padding: 13px 0 12px; }
|
||||
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||||
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
|
||||
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); flex: none; opacity: .35; transition: opacity .2s; }
|
||||
.dot.pulse { opacity: 1; }
|
||||
|
||||
main { max-width: 680px; margin: 0 auto; padding: 16px 14px 40px; }
|
||||
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
|
||||
.label { color: var(--fade); font-size: .72rem; text-transform: uppercase; letter-spacing: .6px; margin: 0 0 10px; }
|
||||
|
||||
/* Header card */
|
||||
.sess-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.sess-title { font-size: 1.25rem; font-weight: 700; }
|
||||
.sess-sub { color: var(--fade); font-size: .9rem; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.chip { font-size: .8rem; color: var(--fade); background: var(--bg-line); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; }
|
||||
.chip b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Stack card */
|
||||
.stack-row { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
||||
.stack-now { font-size: 2.3rem; font-weight: 800; letter-spacing: .2px; font-variant-numeric: tabular-nums; }
|
||||
.net { font-size: 1.2rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.net.up { color: var(--good); } .net.down { color: var(--low); } .net.flat { color: var(--fade); }
|
||||
.stack-meta { color: var(--fade); font-size: .85rem; margin-left: auto; text-align: right; }
|
||||
svg.spark { display: block; width: 100%; height: 56px; margin-top: 14px; }
|
||||
|
||||
/* Hands */
|
||||
ul.rows { list-style: none; margin: 0; padding: 0; }
|
||||
ul.rows li { padding: 10px 0; border-bottom: 1px solid var(--bg-line); font-size: .95rem; line-height: 1.45; }
|
||||
ul.rows li:last-child { border-bottom: none; }
|
||||
a.hand { color: var(--text); text-decoration: none; display: flex; gap: 8px; align-items: baseline; }
|
||||
a.hand:hover { color: var(--accent); }
|
||||
.pos { color: var(--accent); font-weight: 700; min-width: 38px; }
|
||||
.cards { font-variant-numeric: tabular-nums; }
|
||||
.res { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.res.up { color: var(--good); } .res.down { color: var(--low); }
|
||||
.tag { font-size: .7rem; color: var(--mid); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; }
|
||||
.villain b { color: var(--text); } .villain .cat { color: var(--mid); font-size: .78rem; }
|
||||
.note-meta { color: var(--fade); font-size: .72rem; }
|
||||
.empty { color: var(--fade); font-size: .92rem; }
|
||||
.err { color: var(--low); text-align: center; padding: 30px; }
|
||||
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
|
||||
.big-empty .ico { font-size: 2.4rem; }
|
||||
.big-empty a { color: var(--accent); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<span class="dot" id="dot"></span>
|
||||
<h1>🎬 Session</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<a class="back" href="/hands" title="All recorded hands">🃏 Hands</a>
|
||||
<span class="updated" id="updated">—</span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="err" id="boot">Loading the table…</p></main>
|
||||
|
||||
<script>
|
||||
const root = document.getElementById('root');
|
||||
const dot = document.getElementById('dot');
|
||||
const updatedEl = document.getElementById('updated');
|
||||
|
||||
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
|
||||
function money(v){ if (v == null) return '—'; const n = Number(v); return (n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
|
||||
function signed(v){ if (v == null) return '—'; const n = Number(v); return (n>0?'+$':n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
|
||||
|
||||
function ago(iso){
|
||||
if(!iso) return '—';
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
|
||||
if(s < 60) return 'just now';
|
||||
if(s < 3600) return Math.round(s/60)+'m ago';
|
||||
if(s < 86400) return Math.round(s/3600)+'h ago';
|
||||
return Math.round(s/86400)+'d ago';
|
||||
}
|
||||
function elapsed(iso){
|
||||
if(!iso) return '—';
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
|
||||
const h = Math.floor(s/3600), m = Math.round((s%3600)/60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
// Tiny inline sparkline of the stack-over-time series.
|
||||
function sparkline(series){
|
||||
const pts = series.map(p => Number(p.amount)).filter(n => !isNaN(n));
|
||||
if (pts.length < 2) return '';
|
||||
const W = 600, H = 56, pad = 4;
|
||||
const min = Math.min(...pts), max = Math.max(...pts), span = (max - min) || 1;
|
||||
const x = i => pad + (i / (pts.length - 1)) * (W - 2*pad);
|
||||
const y = v => H - pad - ((v - min) / span) * (H - 2*pad);
|
||||
const d = pts.map((v,i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
||||
const last = pts[pts.length-1], first = pts[0];
|
||||
const col = last >= first ? 'var(--good)' : 'var(--low)';
|
||||
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
|
||||
<polyline points="${d}" fill="none" stroke="${col}" stroke-width="2"
|
||||
stroke-linejoin="round" stroke-linecap="round" />
|
||||
<circle cx="${x(pts.length-1).toFixed(1)}" cy="${y(last).toFixed(1)}" r="3" fill="${col}" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; }
|
||||
|
||||
function render(data){
|
||||
const s = data.session;
|
||||
if (!s) {
|
||||
root.innerHTML = `<div class="big-empty">
|
||||
<div class="ico">🪑</div>
|
||||
<p>No live session right now.<br>Start one from <a href="/">chat</a> — switch to ♠ Cash and tell Lyra you're sitting down.</p>
|
||||
</div>`;
|
||||
updatedEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const stack = data.stack || {};
|
||||
const hands = data.hands || [];
|
||||
const villains = data.villains || [];
|
||||
const notes = data.notes || [];
|
||||
const stats = data.stats || {};
|
||||
|
||||
const title = [s.stakes, s.game].filter(Boolean).join(' ') || 'Session';
|
||||
const tagBits = Object.entries(stats.tags || {}).map(([k,v]) => `${k}×${v}`).join(' · ');
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="sess-top">
|
||||
<span class="sess-title">${esc(title)}</span>
|
||||
<span class="sess-sub">${esc(s.venue || 'unknown room')}${s.status && s.status!=='live' ? ' · '+esc(s.status) : ''}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">⏱ <b>${elapsed(s.started_at)}</b></span>
|
||||
<span class="chip">in <b>${money(s.buy_in_total)}</b></span>
|
||||
<span class="chip">${esc(s.format || 'cash')}</span>
|
||||
<span class="chip"><b>${hands.length}</b> hands</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Stack</p>
|
||||
<div class="stack-row">
|
||||
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
|
||||
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
|
||||
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
|
||||
</div>
|
||||
${sparkline(stack.log || [])}
|
||||
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Hands this session</p>
|
||||
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
|
||||
<li><a class="hand" href="/hand/${h.id}">
|
||||
<span class="pos">${esc(h.position || '?')}</span>
|
||||
<span class="cards">${esc(h.hole_cards || '')}${h.board ? ' · '+esc(h.board) : ''}</span>
|
||||
${h.tag ? `<span class="tag">${esc(h.tag)}</span>` : ''}
|
||||
${h.result != null ? `<span class="res ${h.result>=0?'up':'down'}">${signed(h.result)}</span>` : ''}
|
||||
</a></li>`).join('')}</ul>`
|
||||
: '<p class="empty">No hands logged yet.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Villains seen</p>
|
||||
${villains.length ? `<ul class="rows">${villains.map(v => `
|
||||
<li class="villain">
|
||||
<b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
|
||||
${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''}
|
||||
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="empty">No reads logged this session.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Her notes</p>
|
||||
${notes.length ? `<ul class="rows">${notes.map(n => `
|
||||
<li>${esc(n.content)}<div class="note-meta">${esc(n.kind)} · ${ago(n.created_at)}</div></li>`).join('')}</ul>`
|
||||
: '<p class="empty">Nothing jotted this session.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Session stats</p>
|
||||
<div class="chips">
|
||||
<span class="chip">logged <b>${stats.hands_logged ?? 0}</b></span>
|
||||
${tagBits ? `<span class="chip">${esc(tagBits)}</span>` : ''}
|
||||
${stats.context_per_hour != null ? `<span class="chip">${esc(title)} lifetime <b>${signed(stats.context_per_hour)}/hr</b></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
updatedEl.textContent = 'updated ' + ago(data._fetched);
|
||||
}
|
||||
|
||||
async function refresh(){
|
||||
try {
|
||||
const r = await fetch('/session/data', { cache: 'no-store' });
|
||||
const data = await r.json();
|
||||
data._fetched = new Date().toISOString();
|
||||
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
|
||||
render(data);
|
||||
} catch (e) {
|
||||
if (!root.querySelector('.card')) root.innerHTML = '<p class="err">Couldn\'t reach the table. Is the server up?</p>';
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -99,6 +99,29 @@ body {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Mode badge: the always-visible Talk/Cash toggle. Hidden on desktop (the header
|
||||
<select> handles it there); shown in the minimal mobile header (see media query). */
|
||||
.mode-badge {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-console);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-fade);
|
||||
background: var(--bg-line);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 11px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Cash mode: light up the badge (and the chat brand) so the table state is obvious. */
|
||||
body.cash-mode .mode-badge {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
body.cash-mode .brand { color: var(--accent); }
|
||||
|
||||
label, select, button {
|
||||
font-family: var(--font-console);
|
||||
font-size: 0.9rem;
|
||||
@@ -822,10 +845,11 @@ select:hover {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Mobile header is [≡] Lyra … [●] — hide everything else. */
|
||||
#model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot) {
|
||||
/* Mobile header is [≡] Lyra [♠ Cash] [●] — hide everything else. */
|
||||
#model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot):not(.mode-badge) {
|
||||
display: none;
|
||||
}
|
||||
.mode-badge { display: inline-flex; margin-left: 4px; }
|
||||
.brand {
|
||||
display: block;
|
||||
font-family: var(--font-console);
|
||||
|
||||
Reference in New Issue
Block a user