Files
project-lyra/lyra/web/static/hand.html
T
serversdown f2de7dec61 feat(web): shared left-sidebar navigation across all pages
Desktop nav was scattered and inconsistent — the chat header was crammed with
cross-page links and each standalone page had its own ad-hoc, incomplete back-links
(e.g. /hands could only reach Chat). Now a single nav.js (one source of truth, no
build step) injects a left sidebar on desktop (>=769px) with active-page
highlighting across Chat/Session/History/Hands/Mind/Journal/Logs + Settings.

- nav.js: injects sidebar + its own CSS; body gets padding-left on desktop; hidden
  on mobile (each page keeps its bottom bar / back-links there).
- Included on every page (index, session, history, hands, self, journal, logs,
  recap, hand).
- Decluttered the chat header: removed the now-redundant cross-page links (kept the
  chat-specific session selector + inline Live Log toggle).
- Sidebar Settings opens the chat modal, or navigates to /?settings=1 from elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:27:55 +00:00

253 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 — Hand</title>
<style>
:root {
--bg:#070707; --bg-elev:#0e0e0e; --border:#2a1d12; --text:#e8e8e8;
--fade:#8a8a8a; --accent:#ff7a00; --felt:#16322a; --feltline:#0f5132;
--chip:#ffb347; --hero:#ff7a00;
}
*{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:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
main{max-width:760px;margin:0 auto;padding:14px;}
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
.card.sm{width:26px;height:36px;font-size:.8rem;}
.card .r{font-size:1rem;}
.card.red{color:#c8102e;}
.card.back{background:#2a3550;color:#2a3550;}
.card.unknown{background:#2a3550;color:#7c879e;font-size:1.2rem;}
.card .nosuit{color:#9aa3b5;}
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(255,122,0,.4);}
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,179,71,.6);}
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
.seat.folded{opacity:.4;}
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
.controls button{background:#241400;border:1px solid var(--border);color:var(--text);
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.controls button:disabled{opacity:.4;}
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
.log .ln.cur{background:#241400;}
.log .ln.brd{color:var(--fade);font-style:italic;}
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
.err{color:#ff6b6b;text-align:center;padding:40px;}
.net-pos{color:#8fd694;} .net-neg{color:#ff6b6b;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hand</h1>
<a class="back" href="/">← Chat</a>
<span class="sub" id="sub"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
<script>
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
const RED = new Set(["h", "d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code, sm){
if(!code) return '';
const c = String(code).trim();
if(c.toLowerCase()==='x') return `<span class="card${sm?' sm':''} unknown">?</span>`;
const m = c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card${sm?' sm':''}">${esc(c)}</span>`;
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
if(s==='x') return `<span class="card${sm?' sm':''}"><span class="r">${r}</span><span class="nosuit">·</span></span>`;
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
function render(h){
const sub = document.getElementById('sub');
const data = h.structured;
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; }
const players = (data.players||[]).slice();
// order so hero sits at the bottom
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
if(heroIdx < 0) heroIdx = 0;
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
const n = Math.max(ordered.length, 1);
const acts = data.actions || [];
let step = 0; // number of actions applied
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
const root = document.getElementById('root');
root.innerHTML = `
<div class="table-wrap" id="tw">
<div class="felt"></div>
<div class="center">
<div class="street" id="street"></div>
<div class="board" id="board"></div>
<div class="pot" id="pot"></div>
</div>
<div id="seats"></div>
</div>
<div class="now" id="now"></div>
<div class="controls">
<button id="prev">◀ Prev</button>
<span class="step-label" id="steplab"></span>
<button id="next">Next ▶</button>
<button id="all">End</button>
</div>
<div class="log" id="log"></div>
${data.result ? `<div class="summary"><div class="lbl">Result</div>
<div>${esc(data.result.summary||'')}</div>
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
</div>`:''}
`;
// place seats around the oval
const seatsEl = document.getElementById('seats');
const starts = {};
ordered.forEach((p,i)=>{
starts[p.pos] = (p.stack!=null ? Number(p.stack) : null);
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
const el = document.createElement('div');
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
el.style.left = x+'%'; el.style.top = y+'%';
el.dataset.pos = p.pos;
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
+ `<div class="stack" data-stack>${p.stack!=null?esc(p.stack):''}</div>`
+ `<div class="act" data-act></div>`;
seatsEl.appendChild(el);
});
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
// build the log
logEl.innerHTML = acts.map((a,idx)=>{
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
const amt = a.amount!=null ? ' '+a.amount : '';
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
}).join('');
const cap = s => s ? s[0].toUpperCase()+s.slice(1) : s;
const fmt = n => Number.isInteger(n) ? n : Math.round(n*100)/100;
function draw(){
let board = [], street = 'Preflop';
const lastAct = {}, folded = {};
// street-aware chip accounting: amounts are "to" totals for the street
const contrib = {}; // committed in prior (flushed) streets
let streetCommit = {}, streetBet = 0, curStreet = 'preflop';
const flushStreet = () => { for(const p in streetCommit){ contrib[p]=(contrib[p]||0)+streetCommit[p]; } streetCommit={}; streetBet=0; };
for(let i=0;i<step;i++){
const a = acts[i];
if(a.board){ flushStreet(); curStreet=a.street; board=a.board; street=cap(a.street); continue; }
if(a.street && a.street!==curStreet){ flushStreet(); curStreet=a.street; }
if(a.street) street = cap(a.street);
const pos=a.pos, amt=(a.amount!=null?Number(a.amount):null);
if(pos){
switch(a.action){
case 'post': case 'bet': streetCommit[pos]=amt||0; streetBet=Math.max(streetBet, amt||0); break;
case 'raise': case 'allin': streetCommit[pos]=(amt!=null?amt:streetBet); streetBet=Math.max(streetBet, streetCommit[pos]); break;
case 'call': streetCommit[pos]=(amt!=null?amt:streetBet); break;
case 'fold': folded[pos]=true; break;
}
lastAct[pos]=(a.action||'')+(amt!=null?' '+amt:'');
}
}
// committed total per player (flushed streets + current street), pot = sum
const committed={}, allPos=new Set([...Object.keys(contrib),...Object.keys(streetCommit)]);
let pot=0;
allPos.forEach(p=>{ committed[p]=(contrib[p]||0)+(streetCommit[p]||0); pot+=committed[p]; });
boardEl.innerHTML = cards(board);
potEl.textContent = pot ? ('Pot '+fmt(pot)) : '';
streetEl.textContent = street;
document.querySelectorAll('.seat').forEach(s=>{
const pos=s.dataset.pos;
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
s.classList.toggle('folded', !!folded[pos]);
s.classList.remove('acting');
const stEl=s.querySelector('[data-stack]'), start=starts[pos], c=committed[pos]||0;
if(start!=null){ const rem=start-c; stEl.textContent = rem<=0 ? 'all in' : fmt(rem); }
else { stEl.textContent = c ? ''+fmt(c) : ''; }
});
const cur = acts[step-1];
if(cur && cur.pos){
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
if(s) s.classList.add('acting');
}
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
steplab.textContent = `${step} / ${acts.length}`;
document.getElementById('prev').disabled = step===0;
document.getElementById('next').disabled = step>=acts.length;
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
}
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
document.addEventListener('keydown',e=>{
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
});
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
draw();
}
async function load(){
const id = location.pathname.split('/')[2];
try{
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
const h = await r.json();
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
render(h);
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
}
load();
</script>
<script src="/nav.js"></script>
</body>
</html>