From 50bcb5533f7a3016a227bcb8ee3ad2ddbd6126f4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 27 Jun 2026 04:18:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20hand=20recorder=20v1=20(tap-to-build,?= =?UTF-8?q?=20overlay)=20=E2=80=94=20recorder=20steps=202-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap-to-build hand recorder per docs/RECORDER.md. Correctness by construction: every tap writes a known value into a known slot, emitting the canonical structured contract (server normalize_structured() is the shape authority, so the client is best-effort). - recorder.js: mount-agnostic module (mounts into any container -> overlay now, standalone later, zero logic change). Pure buildStructured(state)->contract dict. Card picker: sticky-suit-or-rank-first + x (unknown suit) + unknown-card; lock resets per slot so it never bleeds between cards. Pre-fills from /session/data (stakes->blinds, stack->hero, villains->seated). Per-street action entry, manual street advance. - recorder.css: full-screen overlay using app theme tokens; floating launcher. - index.html: overlay div + script + launcher (chat/session stays mounted underneath). Validated end-to-end: JS core builds a hand (sticky/rainbow/unknown cards all correct) -> POST /hands -> normalized -> /hand/{id} replay round-trips identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/web/static/index.html | 24 ++ lyra/web/static/recorder.css | 134 +++++++++ lyra/web/static/recorder.js | 507 +++++++++++++++++++++++++++++++++++ 3 files changed, 665 insertions(+) create mode 100644 lyra/web/static/recorder.css create mode 100644 lyra/web/static/recorder.js diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index d7f48b5..7c3242d 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -4,6 +4,7 @@ Lyra Core Chat + @@ -1245,5 +1246,28 @@ }); + +
+ + diff --git a/lyra/web/static/recorder.css b/lyra/web/static/recorder.css new file mode 100644 index 0000000..9caf9e3 --- /dev/null +++ b/lyra/web/static/recorder.css @@ -0,0 +1,134 @@ +/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from + style.css when mounted in index.html. For a standalone recorder.html, import those + tokens too (see :root in style.css). */ + +.rec-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: var(--bg-dark, #070707); + display: none; + flex-direction: column; +} +.rec-overlay.open { display: flex; } + +.rec-root { + display: flex; + flex-direction: column; + height: 100%; + color: var(--text-main, #e8e8e8); + font-family: var(--font-console, ui-monospace, monospace); +} + +.rec-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--border, #2a1d12); +} +.rec-title { font-weight: 700; color: var(--accent, #ff7a00); } +.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; } +.rec-x { + background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a); + border-radius: 8px; width: 34px; height: 34px; font-size: 1rem; +} + +.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; } +.rec-sec { margin-bottom: 18px; } +.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; } +.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; } + +.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; } +.rec-pos { + min-width: 44px; padding: 9px 10px; border-radius: 9px; + background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); + border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600; +} +.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); } +.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; } + +.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; } + +.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; } +.rec-seat { display: flex; align-items: center; gap: 8px; } +.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); } +.rec-name { + flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px; + background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12); + color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem; +} +.rec-seat-cards { display: flex; gap: 5px; } +.rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; } + +/* cards */ +.rec-card { + display: inline-flex; align-items: center; justify-content: center; + min-width: 30px; height: 40px; padding: 0 5px; border-radius: 6px; + background: #f4f4f4; color: #111; font-weight: 700; font-size: 1rem; line-height: 1; +} +.rec-card .rs { font-size: .8rem; margin-left: 1px; } +.rec-card.spade, .rec-card.club { color: #111; } +.rec-card.heart { color: #d11; } +.rec-card.diamond { color: #1463d1; } +.rec-card.empty { background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px dashed var(--border-bright, #4a2f15); } +.rec-card.empty.active { border-color: var(--accent, #ff7a00); color: var(--accent, #ff7a00); } +.rec-card.unknown { background: #2a2a2a; color: #aaa; } + +.rec-board { display: flex; gap: 6px; margin: 8px 0; } + +.rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; } +.rec-tab { + flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600; + background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12); +} +.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); } + +.rec-act-add { display: flex; gap: 6px; margin: 8px 0; } +.rec-sel, .rec-num { + padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e); + border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8); + font-family: inherit; font-size: .85rem; min-width: 0; +} +.rec-sel { flex: 1; } +.rec-num { width: 70px; } +.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; } +.rec-result { display: flex; gap: 12px; } +.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); } + +.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; } +.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); } +.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); } +.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; } + +.rec-foot { padding: 12px 14px; border-top: 1px solid var(--border, #2a1d12); } +.rec-save { width: 100%; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; } +.rec-save:disabled { opacity: .6; } + +/* card picker bottom sheet */ +.rec-picker-wrap { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: flex-end; z-index: 1100; } +.rec-picker { width: 100%; background: var(--bg-elev, #0e0e0e); border-top: 1px solid var(--border-bright, #4a2f15); border-radius: 16px 16px 0 0; padding: 14px; } +.rec-picker-head { display: flex; align-items: center; justify-content: space-between; font-size: .85rem; color: var(--text-fade, #8a8a8a); margin-bottom: 12px; } +.rec-pk-x { background: var(--border-bright, #4a2f15); color: #fff; border: none; border-radius: 8px; padding: 6px 12px; font-weight: 600; } +.rec-suits { display: flex; gap: 8px; margin-bottom: 12px; } +.rec-suit { flex: 1; height: 52px; border-radius: 10px; font-size: 1.6rem; background: #f4f4f4; border: 2px solid transparent; } +.rec-suit.spade, .rec-suit.club { color: #111; } +.rec-suit.heart { color: #d11; } +.rec-suit.diamond { color: #1463d1; } +.rec-suit.x { background: #2a2a2a; color: #aaa; font-size: 1.1rem; font-weight: 700; } +.rec-suit.on { border-color: var(--accent, #ff7a00); box-shadow: 0 0 0 2px var(--accent, #ff7a00); } +.rec-ranks { display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; } +.rec-rank { height: 46px; border-radius: 9px; font-size: 1.05rem; font-weight: 700; background: var(--bg-dark, #070707); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12); } +.rec-rank.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); } +.rec-pk-foot { display: flex; justify-content: space-between; margin-top: 12px; } +.rec-pk-foot button { background: var(--bg-dark, #070707); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); border-radius: 8px; padding: 9px 14px; font-size: .85rem; } + +/* floating launcher (V1) */ +#recLaunch { + position: fixed; right: 16px; bottom: 84px; z-index: 900; + background: var(--accent, #ff7a00); color: #111; border: none; + border-radius: 22px; padding: 11px 16px; font-weight: 700; font-size: .9rem; + font-family: var(--font-console, ui-monospace, monospace); + box-shadow: 0 3px 12px rgba(0,0,0,.4); +} +body.kb #recLaunch { display: none; } /* hide when the keyboard is up */ diff --git a/lyra/web/static/recorder.js b/lyra/web/static/recorder.js new file mode 100644 index 0000000..9be2165 --- /dev/null +++ b/lyra/web/static/recorder.js @@ -0,0 +1,507 @@ +/* Hand recorder — tap-to-build poker hands. See docs/RECORDER.md. + * + * Correctness by construction: every tap writes a known value into a known slot, + * so there's no parse step that can be wrong. Output is the canonical structured + * contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final + * authority on shape, so this stays best-effort. + * + * Mount-agnostic: Recorder.mount(container, opts) renders into ANY element — a + * full-screen overlay in index.html today, a standalone recorder.html later, with + * zero logic changes. buildStructured(state) is pure (no DOM) — the reusable core. + */ +(function () { + "use strict"; + + // --- constants ------------------------------------------------------------ + const SUITS = [ + { s: "s", sym: "♠", cls: "spade" }, + { s: "h", sym: "♥", cls: "heart" }, + { s: "d", sym: "♦", cls: "diamond" }, + { s: "c", sym: "♣", cls: "club" }, + ]; + const RANKS = ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"]; + const POSITIONS = ["UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB"]; + const STREETS = ["preflop", "flop", "turn", "river"]; + const STREET_BOARD = { flop: 3, turn: 1, river: 1 }; // cards revealed per street + const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"]; + const SIZED = { bet: true, raise: true, allin: true }; // actions that carry an amount + + // --- pure core: state -> contract dict (testable, no DOM) ------------------ + function buildStructured(state) { + const players = state.seats + .filter((s) => s.pos) + .map((s) => { + const p = { pos: s.pos }; + if (s.stack != null) p.stack = s.stack; + if (s.name) p.name = s.name; + p.cards = s.cards && s.cards.length ? s.cards.slice() : null; + return p; + }); + + // One flat chronological action list; each street opens with its board reveal. + const actions = []; + for (const st of STREETS) { + const reveal = state.board[st]; + if (st !== "preflop" && reveal && reveal.length) { + actions.push({ street: st, board: reveal.slice() }); + } + for (const a of state.actions.filter((x) => x.street === st)) { + actions.push({ + street: st, + pos: a.pos, + action: a.action, + amount: a.amount != null ? a.amount : null, + }); + } + } + + const hero = state.seats.find((s) => s.pos === state.heroPos); + const board = [].concat(state.board.flop, state.board.turn, state.board.river); + return { + game: state.meta.game || "NLH", + stakes: state.meta.stakes || null, + hero_pos: state.heroPos || null, + hero_cards: hero && hero.cards ? hero.cards.slice() : [], + players, + actions, + board, + result: { + pot: state.result.pot, + hero_net: state.result.heroNet, + summary: state.result.summary || "", + }, + }; + } + + // --- state construction --------------------------------------------------- + function parseBlinds(stakes) { + const m = (stakes || "").match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/); + return m ? { sb: parseFloat(m[1]), bb: parseFloat(m[2]) } : { sb: null, bb: null }; + } + + function initialState(hud) { + const sess = (hud && hud.session) || {}; + const stack = (hud && hud.stack) || {}; + const blinds = parseBlinds(sess.stakes); + + // Seat known villains where Lyra has a read; hero added on position pick. + const seats = []; + for (const v of (hud && hud.villains) || []) { + if (v.seat && POSITIONS.includes(v.seat)) { + seats.push({ pos: v.seat, name: v.name || null, stack: null, cards: null }); + } + } + + const actions = []; + if (blinds.sb != null) actions.push({ street: "preflop", pos: "SB", action: "post", amount: blinds.sb }); + if (blinds.bb != null) actions.push({ street: "preflop", pos: "BB", action: "post", amount: blinds.bb }); + + return { + meta: { + game: sess.game || "NLH", + stakes: sess.stakes || null, + venue: sess.venue || null, + sessionId: sess.id != null ? sess.id : null, + }, + blinds, + heroStack: stack.current != null ? stack.current : null, + heroPos: null, + seats, + street: "preflop", + board: { flop: [], turn: [], river: [] }, + actions, + result: { pot: null, heroNet: null, summary: "" }, + // transient editor: + pick: null, // active card target: {type:'hero'|'board'|'seat', pos?} + lockedSuit: null, + pendingRank: null, + }; + } + + // --- card picker logic ---------------------------------------------------- + function pickTargetCards(state) { + const p = state.pick; + if (!p) return null; + if (p.type === "hero") { + const hero = ensureHero(state); + return { arr: (hero.cards = hero.cards || []), max: 2 }; + } + if (p.type === "seat") { + const seat = state.seats.find((s) => s.pos === p.pos); + if (!seat) return null; + return { arr: (seat.cards = seat.cards || []), max: 2 }; + } + if (p.type === "board") { + return { arr: state.board[p.street], max: STREET_BOARD[p.street] }; + } + return null; + } + + function pushCard(state, token) { + const t = pickTargetCards(state); + if (!t || t.arr.length >= t.max) return; + t.arr.push(token); + if (t.arr.length >= t.max) state.pick = null; // slot full -> close picker + } + + function onSuitTap(state, suit) { + state.lockedSuit = suit; + if (state.pendingRank) { + pushCard(state, state.pendingRank + suit); + state.pendingRank = null; + } + } + + function onRankTap(state, rank) { + if (state.lockedSuit) { + pushCard(state, rank + state.lockedSuit); + } else { + state.pendingRank = rank; // await a suit (or x) + } + } + + function onUnknownSuit(state) { + // 'x' suit: complete a pending rank as unknown-suit (e.g. "Ax"); flips + // completeness on the server. No-op without a pending rank. + if (state.pendingRank) { + pushCard(state, state.pendingRank + "x"); + state.pendingRank = null; + } + } + + function onUnknownCard(state) { + state.pendingRank = null; + pushCard(state, "x"); // a card never shown + } + + function backspaceCard(state) { + const t = pickTargetCards(state); + if (t && t.arr.length) t.arr.pop(); + state.pendingRank = null; + } + + function ensureHero(state) { + let hero = state.seats.find((s) => s.pos === state.heroPos); + if (!hero && state.heroPos) { + hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] }; + state.seats.push(hero); + } + return hero || {}; + } + + // expose pure + helpers for testing / V2 reuse + window.Recorder = { + buildStructured, + parseBlinds, + initialState, + _internals: { onSuitTap, onRankTap, onUnknownSuit, onUnknownCard, pushCard, POSITIONS, STREETS }, + mount, + }; + + // --- mount / render (DOM shell) ------------------------------------------ + async function mount(container, opts) { + opts = opts || {}; + let hud = opts.hud; + if (!hud) { + try { + const url = opts.sessionId != null ? `/session/data?id=${opts.sessionId}` : "/session/data"; + hud = await fetch(url).then((r) => r.json()); + } catch (e) { + hud = { session: null }; + } + } + const state = initialState(hud); + const ctx = { container, state, opts }; + + container.classList.add("rec-root"); + container.addEventListener("click", (e) => handleClick(ctx, e)); + container.addEventListener("input", (e) => handleInput(ctx, e)); + render(ctx); + return ctx; + } + + function h(html) { + return html; + } + + function cardChip(token, active) { + if (!token) return `+`; + if (token === "x") return `?`; + const rank = token[0]; + const suit = token[1]; + const meta = SUITS.find((x) => x.s === suit); + const cls = meta ? meta.cls : "unknown"; + const sym = meta ? meta.sym : "?"; + return `${rank}${sym}`; + } + + function render(ctx) { + const s = ctx.state; + const heroCards = (s.seats.find((x) => x.pos === s.heroPos) || {}).cards || []; + ctx.container.innerHTML = ` +
+
Record hand
+
${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}
+ +
+ +
+
+
Your seat
+
+ ${POSITIONS.map((p) => ``).join("")} +
+
+ cards + ${cardChip(heroCards[0], s.pick && s.pick.type === "hero" && heroCards.length === 0)} + ${cardChip(heroCards[1], s.pick && s.pick.type === "hero" && heroCards.length === 1)} +
+
+ +
+
Players in the hand
+
+ ${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '
none yet
'} +
+
+ ${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => ``).join("")} +
+
+ +
+
Streets
+
+ ${STREETS.map((st) => ``).join("")} +
+ ${renderStreet(ctx)} +
+ +
+
Result
+
+ + +
+
+
+ +
+ +
+ + ${s.pick ? renderPicker(s) : ""} + `; + } + + function renderSeat(seat) { + const cards = seat.cards || []; + return ` +
+ ${seat.pos} + + + ${cardChip(cards[0])} ${cardChip(cards[1])} + + +
`; + } + + function renderStreet(ctx) { + const s = ctx.state; + const st = s.street; + const board = s.board[st] || []; + const need = STREET_BOARD[st] || 0; + const boardSlots = + st === "preflop" + ? "" + : `
+ ${Array.from({ length: need }).map((_, i) => cardChip(board[i], s.pick && s.pick.type === "board" && s.pick.street === st && board.length === i)).join("")} +
`; + const players = s.seats.map((x) => x.pos); + const acts = s.actions.filter((a) => a.street === st && a.action !== "post"); + return ` + ${boardSlots} +
+ + + + +
+
+ ${(s.board[st] && s.board[st].length && st !== "preflop") ? `
${st}: ${s.board[st].map((c) => cardChip(c)).join("")}
` : ""} + ${s.actions.filter((a) => a.street === st).map((a) => `
${a.pos} ${a.action}${a.amount != null ? " " + a.amount : ""}
`).join("")} +
`; + } + + function renderPicker(s) { + const pend = s.pendingRank; + return ` +
+
+
+ ${pend ? `pick a suit for ${pend}` : "tap a suit (locks) or a rank"} + +
+
+ ${SUITS.map((su) => ``).join("")} + +
+
+ ${RANKS.map((r) => ``).join("")} +
+
+ + +
+
+
`; + } + + function boardCount(s, st) { + const n = (s.board[st] || []).length; + return n ? ` ${n}` : ""; + } + + // --- events --------------------------------------------------------------- + function handleClick(ctx, e) { + const s = ctx.state; + const t = e.target.closest("[data-act]"); + if (!t) return; + const act = t.getAttribute("data-act"); + + switch (act) { + case "close": + if (ctx.opts.onClose) ctx.opts.onClose(); + return; + case "hero-pos": { + const pos = t.getAttribute("data-pos"); + // moving hero seat: drop any old hero-only seat, ensure new + const old = s.seats.find((x) => x.pos === s.heroPos); + if (old && old.name === "Hero" && !(old.cards || []).length) { + s.seats = s.seats.filter((x) => x !== old); + } + s.heroPos = s.heroPos === pos ? null : pos; + if (s.heroPos) ensureHero(s); + break; + } + case "add-seat": + s.seats.push({ pos: t.getAttribute("data-pos"), name: null, stack: null, cards: null }); + break; + case "rm-seat": + s.seats = s.seats.filter((x) => x.pos !== t.getAttribute("data-pos")); + break; + case "street": + s.street = t.getAttribute("data-street"); + break; + case "pick": + // A new slot starts neutral: first suit tapped locks for the rest of THIS slot + // (flush flops stay fast), first rank tapped waits for its suit. The lock never + // bleeds from one slot into the next. + s.pick = pickFromEl(t); + s.pendingRank = null; + s.lockedSuit = null; + break; + case "pick-close": + case "picker-bg": + if (act === "picker-bg" && e.target.closest("[data-stop]")) return; + s.pick = null; + s.pendingRank = null; + break; + case "suit": + onSuitTap(s, t.getAttribute("data-s")); + break; + case "xsuit": + onUnknownSuit(s); + break; + case "rank": + onRankTap(s, t.getAttribute("data-r")); + break; + case "xcard": + onUnknownCard(s); + break; + case "backspace": + backspaceCard(s); + break; + case "add-action": + addActionFromControls(ctx); + break; + case "save": + return doSave(ctx); + default: + return; // inputs handled in handleInput + } + render(ctx); + } + + function handleInput(ctx, e) { + const s = ctx.state; + const t = e.target.closest("[data-act]"); + if (!t) return; + const act = t.getAttribute("data-act"); + if (act === "seat-name") { + const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos")); + if (seat) seat.name = t.value.trim() || null; + } else if (act === "result") { + const k = t.getAttribute("data-k"); + s.result[k] = t.value === "" ? null : parseFloat(t.value); + } + // na-* and these inputs don't need a re-render mid-typing + } + + function pickFromEl(t) { + const type = t.getAttribute("data-type"); + if (type === "board") return { type, street: t.getAttribute("data-street") }; + if (type === "seat") return { type, pos: t.getAttribute("data-pos") }; + return { type: "hero" }; + } + + function addActionFromControls(ctx) { + const root = ctx.container; + const pos = root.querySelector('[data-act="na-pos"]').value; + const action = root.querySelector('[data-act="na-action"]').value; + const amtEl = root.querySelector('[data-act="na-amount"]'); + const amt = amtEl.value; + if (!pos || !action) return; + const entry = { street: ctx.state.street, pos, action }; + if (SIZED[action] && amt !== "") entry.amount = parseFloat(amt); + else entry.amount = null; + ctx.state.actions.push(entry); + } + + async function doSave(ctx) { + const structured = buildStructured(ctx.state); + const btn = ctx.container.querySelector(".rec-save"); + if (btn) { + btn.disabled = true; + btn.textContent = "Saving…"; + } + try { + const res = await fetch("/hands", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ structured, session_id: ctx.state.meta.sessionId }), + }).then((r) => r.json()); + if (res && res.ok) { + if (ctx.opts.onSave) ctx.opts.onSave(res.id); + else window.location.href = `/hand/${res.id}`; + } else { + throw new Error((res && res.error) || "save failed"); + } + } catch (err) { + if (btn) { + btn.disabled = false; + btn.textContent = "Save failed — retry"; + } + } + } + + function esc(x) { + return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + } +})();