Seven tasks: contract module + conformance test, direct capture endpoints (stack/buyin/start), hands API, reads/players API + route conformance, chat-page stack quick-capture box, HUD quick inputs + villain rename, and the iOS-PWA bottom safe-area fix. TDD with real test/impl code grounded in the actual poker.py signatures and FastAPI route patterns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
31 KiB
Poker Logging Service Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn lyra/poker.py into a standalone logging system-of-record with a complete REST API, a single source-of-truth tool/API contract, and a human UI to log and correct everything — usable by Brian with zero LLM dependency.
Architecture: Thin FastAPI routes wrap the existing (already-working) poker.py store functions; a declarative poker_contract.py pins operation names + required args so the REST API and Lyra's LLM tool specs can't drift; the web UI gets dumb capture inputs (2nd stack box on chat, quick inputs on the HUD) and correction controls. This is sub-project 1 of 2; Lyra's classifier/prompts (sub-project 2) are parked.
Tech Stack: Python 3.11+ (venv runs 3.14), FastAPI + uvicorn, SQLite (WAL), pytest, vanilla HTML/JS/CSS.
Global Constraints
- Python files: start with
from __future__ import annotations; 4-space indent; ruffline-length = 100,target-version = "py311". lyra/web/static/index.htmluses CRLF (\r\n) line endings and mixed tabs/spaces. Every other static file (session.html,style.css,nav.js) and all Python use LF + spaces. Match the file you edit or you produce a noisy diff.- Pure data capture (stack / buy-in / cash-out / hand / read) must reach the store via the REST endpoints, never through the chat/LLM path.
poker_contract.pyis the single source of truth: REST routes andtools.pyspecs must agree with it (enforced by a conformance test).- Web app runs via
lyra-web(uvicorn) on0.0.0.0:7078. DB path fromLYRA_DB_PATH(defaultdata/lyra.db, WAL). - Test idiom: fixture sets
LYRA_DB_PATHto atmp_pathfile, stubsllm.embed(andllm.completewhere needed), thenimportlib.reload(memory)thenimportlib.reload(poker)(order matters), thenimportlib.reload(server)for endpoint tests. Run with.venv/bin/pytest(oruv run pytest). - Existing store facts to respect:
start_session(...)usesfmt=(column isformat);add_buyinreturns a float total;log_stackreturns thestack_statedict{current, buy_in, net};end_session(cash_out, ...)takescash_outfirst;hud()returnsNonewhen no session;_HAND_FIELDS = ("position","hole_cards","board","preflop","flop","turn","river","showdown","pot","result","stack_after","tag","lesson");upsert_player(name, **fields)returns an int player id;tools.dispatch(name, args, ctx)—ctxis a plain dict.
Task 1: Contract module + tool-spec conformance test
Files:
- Create:
lyra/poker_contract.py - Create:
tests/test_poker_contract.py
Interfaces:
-
Produces:
lyra.poker_contract.OPERATIONS: dict[str, dict]andCONTRACT_VERSION: int. Each op value:{"required": tuple[str,...], "llm_tool": str | None, "rest": tuple[str, str] | None}whererestis(METHOD, PATH)with PATH exactly matching the FastAPI route template. -
Step 1: Write the contract module
lyra/poker_contract.py:
from __future__ import annotations
# Single source of truth for poker logging operations. The REST API, Lyra's LLM
# tool specs, the human UI, and (later) an MCP wrapper all derive from this.
# `required` MUST match the `required` list in the matching tools.py spec.
# `rest` PATH MUST match the FastAPI route template verbatim.
CONTRACT_VERSION = 1
OPERATIONS: dict[str, dict] = {
"start_session": {"required": (), "llm_tool": "start_session", "rest": ("POST", "/session")},
"update_session": {"required": (), "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")},
"end_session": {"required": ("cash_out",), "llm_tool": "end_session", "rest": None},
"log_stack": {"required": ("amount",), "llm_tool": "log_stack", "rest": ("POST", "/session/stack")},
"add_buyin": {"required": ("amount",), "llm_tool": "add_buyin", "rest": ("POST", "/session/buyin")},
"log_hand": {"required": (), "llm_tool": "log_hand", "rest": ("POST", "/session/hand")},
"update_hand": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/hand/{hand_id}")},
"add_read": {"required": ("note",), "llm_tool": "add_read", "rest": ("POST", "/session/read")},
"update_player": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/player/{player_id}")},
}
- Step 2: Write the failing conformance test
tests/test_poker_contract.py:
from __future__ import annotations
from lyra import tools
from lyra.poker_contract import OPERATIONS
def test_llm_tool_required_args_match_contract():
for op, decl in OPERATIONS.items():
name = decl["llm_tool"]
if not name:
continue
spec = tools.TOOLS[name]["spec"]
required = set(spec["function"]["parameters"]["required"])
assert required == set(decl["required"]), (
f"{op}: tools spec required {required} != contract {set(decl['required'])}"
)
- Step 3: Run the test
Run: .venv/bin/pytest tests/test_poker_contract.py -v
Expected: PASS (the contract's required tuples were copied from the live specs).
- Step 4: Commit
git add lyra/poker_contract.py tests/test_poker_contract.py
git commit -m "feat: poker operation contract + tool-spec conformance test"
Task 2: Direct capture endpoints (stack / buy-in / start)
Files:
- Modify:
lyra/web/server.py(add three routes insidecreate_app, near the existingPATCH /session/{session_id}at server.py:116) - Create:
tests/test_poker_api.py
Interfaces:
-
Consumes:
poker.log_stack(amount, note=None),poker.add_buyin(amount),poker.start_session(venue=, stakes=, game=, fmt=, buy_in=, mantra=),poker.live_session(). -
Produces:
POST /session/stack→{ok, stack}or{ok:false, error};POST /session/buyin→{ok, buy_in_total};POST /session→{ok, id}. -
Step 1: Write the failing endpoint tests
tests/test_poker_api.py:
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.web.server as server
importlib.reload(server)
from fastapi.testclient import TestClient
return TestClient(server.app), poker
def test_post_stack_logs_and_returns_state(client):
c, poker = client
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
r = c.post("/session/stack", json={"amount": 373})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["stack"]["current"] == 373
assert body["stack"]["net"] == pytest.approx(-27)
def test_post_stack_without_session_errors(client):
c, _ = client
r = c.post("/session/stack", json={"amount": 373})
assert r.json()["ok"] is False
assert "error" in r.json()
def test_post_buyin_increments_total(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/buyin", json={"amount": 200})
assert r.json()["buy_in_total"] == pytest.approx(600)
def test_post_session_starts_live(client):
c, poker = client
r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400})
sid = r.json()["id"]
assert poker.live_session()["id"] == sid
- Step 2: Run to verify it fails
Run: .venv/bin/pytest tests/test_poker_api.py -v
Expected: FAIL with 404s (routes not defined). If it errors with "No module named 'httpx'", run .venv/bin/pip install httpx (TestClient needs it).
- Step 3: Add the three routes
In lyra/web/server.py, immediately after the PATCH /session/{session_id} handler (server.py:122), add:
@app.post("/session/stack")
async def session_log_stack(request: Request) -> dict:
"""Log Brian's current stack directly (no LLM). Server-stamps the time."""
body = await request.json()
try:
amount = float(body.get("amount"))
except (TypeError, ValueError):
return {"ok": False, "error": "amount must be a number"}
note = (body.get("note") or "").strip() or None
try:
state = await asyncio.to_thread(poker.log_stack, amount, note)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "stack logged (direct)", amount=amount)
return {"ok": True, "stack": state}
@app.post("/session/buyin")
async def session_add_buyin(request: Request) -> dict:
"""Add a buy-in/rebuy directly (no LLM)."""
body = await request.json()
try:
amount = float(body.get("amount"))
except (TypeError, ValueError):
return {"ok": False, "error": "amount must be a number"}
try:
total = await asyncio.to_thread(poker.add_buyin, amount)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "buyin added (direct)", amount=amount)
return {"ok": True, "buy_in_total": total}
@app.post("/session")
async def session_start(request: Request) -> dict:
"""Open a new live session directly (no LLM)."""
body = await request.json()
sid = await asyncio.to_thread(lambda: poker.start_session(
venue=body.get("venue"), stakes=body.get("stakes"),
game=body.get("game") or "NLH", fmt=body.get("format") or "cash",
buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"),
))
logbus.log("info", "poker session started (direct)", id=sid)
return {"ok": True, "id": sid}
- Step 4: Run to verify it passes
Run: .venv/bin/pytest tests/test_poker_api.py -v
Expected: PASS (4 tests).
- Step 5: Commit
git add lyra/web/server.py tests/test_poker_api.py
git commit -m "feat: direct REST endpoints for stack/buyin/start-session (no LLM)"
Task 3: Hands API (log / edit / delete)
Files:
- Modify:
lyra/poker.py(addupdate_handnearlog_handat poker.py:558) - Modify:
lyra/web/server.py(add routes after the Task 2 routes) - Modify:
tests/test_poker_api.py(add tests)
Interfaces:
-
Consumes:
poker.log_hand(**fields),poker.get_hand(id),poker.delete_entry("hand", id),_HAND_FIELDS. -
Produces:
poker.update_hand(hand_id, **fields) -> dict | None;POST /session/hand→{ok, id};PATCH /hand/{hand_id}→{ok, hand};DELETE /hand/{hand_id}→{ok}. -
Step 1: Write the failing tests
Append to tests/test_poker_api.py:
def test_post_hand_edit_and_delete(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120})
assert r.json()["ok"] is True
hid = r.json()["id"]
r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"})
assert r2.json()["ok"] is True
assert r2.json()["hand"]["hole_cards"] == "2c2d"
r3 = c.delete(f"/hand/{hid}")
assert r3.json()["ok"] is True
assert poker.get_hand(hid) is None
- Step 2: Run to verify it fails
Run: .venv/bin/pytest tests/test_poker_api.py::test_post_hand_edit_and_delete -v
Expected: FAIL (404 on /session/hand).
- Step 3: Add
update_handto the store
In lyra/poker.py, immediately after log_hand (poker.py:558), add:
def update_hand(hand_id: int, **fields) -> dict | None:
"""Edit a logged hand's flat fields (fix a mislabeled board, result, villain).
Only known columns are touched. Returns the updated hand row or None."""
sets, vals = [], []
for k, v in fields.items():
if k in _HAND_FIELDS and v is not None:
sets.append(f"{k} = ?")
vals.append(v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?",
(*vals, hand_id))
return get_hand(hand_id)
- Step 4: Add the three routes
In lyra/web/server.py, after the Task 2 routes, add:
@app.post("/session/hand")
async def session_log_hand(request: Request) -> dict:
"""Log a hand directly with flat fields (no LLM parse)."""
body = await request.json()
try:
hid = await asyncio.to_thread(lambda: poker.log_hand(**body))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
logbus.log("info", "hand logged (direct)", id=hid)
return {"ok": True, "id": hid}
@app.patch("/hand/{hand_id}")
async def hand_update(hand_id: int, request: Request) -> dict:
"""Edit a logged hand's flat fields."""
body = await request.json()
h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body))
logbus.log("info", "hand edited", id=hand_id, fields=list(body))
return {"ok": h is not None, "hand": h}
@app.delete("/hand/{hand_id}")
async def hand_delete(hand_id: int) -> dict:
"""Delete a logged hand."""
ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id)
return {"ok": ok}
- Step 5: Run to verify it passes
Run: .venv/bin/pytest tests/test_poker_api.py -v
Expected: PASS (all tests, including the new hand test).
- Step 6: Commit
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py
git commit -m "feat: hands API — log_hand endpoint, update_hand store fn, edit/delete routes"
Task 4: Reads/players API + route conformance
Files:
- Modify:
lyra/poker.py(addupdate_playernearupsert_player) - Modify:
lyra/web/server.py(add routes) - Modify:
tests/test_poker_api.py(add tests) - Modify:
tests/test_poker_contract.py(add route-coverage test)
Interfaces:
-
Consumes:
poker.add_read(note=, name=, ...),poker.upsert_player(name, **fields). -
Produces:
poker.update_player(player_id, **fields) -> dict | None;POST /session/read→{ok, id};PATCH /player/{player_id}→{ok, player}. -
Step 1: Write the failing tests
Append to tests/test_poker_api.py:
def test_post_read(client):
c, poker = client
poker.start_session(buy_in=400)
r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"})
assert r.json()["ok"] is True
assert isinstance(r.json()["id"], int)
def test_rename_player_fixes_mislabel(client):
c, poker = client
pid = poker.upsert_player("Dave the rock", category="reg")
r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"})
assert r.json()["ok"] is True
assert r.json()["player"]["name"] == "Dave the mechanic"
Append to tests/test_poker_contract.py:
def test_rest_routes_registered():
import lyra.web.server as server
registered = set()
for route in server.app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if not methods or not path:
continue
for m in methods:
registered.add((m, path))
for op, decl in OPERATIONS.items():
if not decl["rest"]:
continue
method, path = decl["rest"]
assert (method, path) in registered, f"{op}: {method} {path} not registered"
- Step 2: Run to verify it fails
Run: .venv/bin/pytest tests/test_poker_api.py::test_rename_player_fixes_mislabel tests/test_poker_contract.py::test_rest_routes_registered -v
Expected: FAIL (404 on /player/...; route-coverage missing several POST/PATCH paths).
- Step 3: Add
update_playerto the store
In lyra/poker.py, immediately after upsert_player (find it near poker.py:1010), add:
_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category")
def update_player(player_id: int, **fields) -> dict | None:
"""Edit a player's dossier (rename, fix tendencies/category). Returns the row or None."""
sets, vals = [], []
for k, v in fields.items():
if k in _PLAYER_FIELDS and v is not None:
sets.append(f"{k} = ?")
vals.append(v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?",
(*vals, player_id))
row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
return dict(row) if row else None
- Step 4: Add the two routes
In lyra/web/server.py, after the Task 3 routes, add:
@app.post("/session/read")
async def session_add_read(request: Request) -> dict:
"""Log a read directly (no LLM); upserts the villain file when name is given."""
body = await request.json()
rid = await asyncio.to_thread(lambda: poker.add_read(
note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"),
tendencies=body.get("tendencies"), adjustment=body.get("adjustment"),
description=body.get("description"), category=body.get("category"),
venue=body.get("venue"),
))
return {"ok": True, "id": rid}
@app.patch("/player/{player_id}")
async def player_update(player_id: int, request: Request) -> dict:
"""Edit a player's dossier (rename, fix tendencies)."""
body = await request.json()
p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body))
logbus.log("info", "player edited", id=player_id, fields=list(body))
return {"ok": p is not None, "player": p}
- Step 5: Run to verify it passes
Run: .venv/bin/pytest tests/test_poker_api.py tests/test_poker_contract.py -v
Expected: PASS (all API tests + both conformance tests).
- Step 6: Run the full suite (no regressions)
Run: .venv/bin/pytest -q
Expected: PASS (existing poker/tools/chat tests still green).
- Step 7: Commit
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py tests/test_poker_contract.py
git commit -m "feat: reads/players API + REST route conformance test"
Task 5: Chat-page stack quick-capture (2nd input box)
Files:
- Modify:
lyra/web/static/index.html(CRLF + tabs — add markup + JS) - Modify:
lyra/web/static/style.css(LF + spaces — add styling)
Interfaces:
-
Consumes:
POST /session/stack(Task 2). ReadscurrentSessionand the Live Log DOM (#thinkingContent,#thinkingEmpty) already present in index.html. -
Produces: a stack-only input that logs without any chat/LLM call.
-
Step 1: Add the input row markup
In lyra/web/static/index.html, insert between the <div id="input">…</div> block (ends ~index.html:125) and <nav id="tabbar"> (index.html:128). Use CRLF + tab indentation to match the file.
<!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
<div id="stackQuick">
<input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
<button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
</div>
- Step 2: Add the JS
In the <script> of index.html, near sendMessage (index.html:299), add (CRLF + tabs):
function liveLogLine(text) {
const content = document.getElementById("thinkingContent");
const empty = document.getElementById("thinkingEmpty");
if (empty) empty.style.display = "none";
const div = document.createElement("div");
div.className = "thinking-event";
div.textContent = text;
content.appendChild(div);
content.scrollTop = content.scrollHeight;
}
async function logStackQuick() {
const el = document.getElementById("stackQuickInput");
const raw = (el.value || "").replace(/[^0-9.]/g, "");
if (!raw) return;
const amount = Number(raw);
try {
const r = await fetch("/session/stack", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount })
});
const data = await r.json();
if (!data.ok) { liveLogLine("⚠ " + (data.error || "stack not logged")); return; }
const t = new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
const net = (data.stack && data.stack.net != null)
? ` (net ${data.stack.net >= 0 ? "+" : ""}${data.stack.net})` : "";
liveLogLine(`💰 $${amount} logged · ${t}${net}`);
el.value = "";
} catch (e) {
liveLogLine("⚠ stack log failed: " + e.message);
}
}
document.getElementById("stackQuickBtn").addEventListener("click", logStackQuick);
document.getElementById("stackQuickInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); logStackQuick(); }
});
- Step 3: Add styling
In lyra/web/static/style.css (LF + spaces), add:
#stackQuick {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 12px;
border-top: 1px solid var(--border, #222);
}
#stackQuick input {
flex: 1;
min-width: 0;
padding: 8px 10px;
background: var(--panel, #111);
color: inherit;
border: 1px solid var(--border, #333);
border-radius: 8px;
}
#stackQuick button {
padding: 8px 14px;
background: var(--accent, #ff7a18);
color: #000;
border: none;
border-radius: 8px;
font-weight: 600;
}
- Step 4: Verify manually
Start the app: .venv/bin/python -m lyra.web.server (serves on :7078). With a live session (start one via the HUD or curl -XPOST localhost:7078/session -d '{"buy_in":400}' -H 'Content-Type: application/json'):
-
The stack box appears below the message input, above the nav icons.
-
Type
350, press Enter → a💰 $350 logged · …line appears in the Live Log, the box clears, and no chat bubble is added. -
Confirm persisted:
curl -s localhost:7078/session/data | python -m json.toolshowsstack.current == 350. -
Step 5: Commit
git add lyra/web/static/index.html lyra/web/static/style.css
git commit -m "feat: stack quick-capture box on chat page (no LLM)"
Task 6: HUD quick-capture + correction controls
Files:
- Modify:
lyra/web/static/session.html(LF + spaces — Stack card markup, villain rename control, JS functions)
Interfaces:
-
Consumes:
POST /session/stack,POST /session/buyin(Task 2),PATCH /session/{id}(existing),PATCH /player/{id}(Task 4). Reads existing globalscurSession,refresh(), and the villain render block. -
Step 1: Add quick inputs to the Stack card
In lyra/web/static/session.html, replace the Stack card block (session.html:280-289) with the same block plus a quick row before its closing </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 || [])}
<div class="quick">
<input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
<button onclick="postStack()">Log stack</button>
<input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
<button onclick="postBuyin()">Add buy-in</button>
<input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
<button onclick="postCashout()">Cash out</button>
</div>
</div>
- Step 2: Add the quick-capture + rename JS functions
In the <script> of session.html, near saveEdit() (session.html:192), add:
async function postQuick(url, amount, body){
const r = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body || { amount }) });
const d = await r.json();
if(!d.ok){ alert(d.error || 'failed'); return false; }
refresh(); return true;
}
function numVal(id){ const el = document.getElementById(id); return Number((el.value||'').replace(/[^0-9.]/g,'')); }
async function postStack(){ const v = numVal('qStack'); if(v) { if(await postQuick('/session/stack', v)) document.getElementById('qStack').value=''; } }
async function postBuyin(){ const v = numVal('qBuyin'); if(v) { if(await postQuick('/session/buyin', v)) document.getElementById('qBuyin').value=''; } }
async function postCashout(){
if(!curSession) return;
const v = numVal('qCashout'); if(!v) return;
const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ cash_out: v }) });
if(!(await r.json()).ok){ alert('failed'); return; }
document.getElementById('qCashout').value=''; refresh();
}
async function renamePlayer(id, current){
const name = prompt('Rename player', current || ''); if(!name) return;
const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ name }) });
if(!(await r.json()).ok){ alert('failed'); return; }
refresh();
}
- Step 3: Add the rename control to the villains list
In session.html, find the villains render block in render(data) (it maps over data.villains / the villains array). For each villain item, add a rename affordance next to the name, using the player id field present on the villain row (commonly v.id or v.player_id — use whichever the bundle provides):
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'')}')">✎</button>
Read the existing villain block first to splice this in cleanly and confirm the id field name.
- Step 4: Add minimal styling
In the inline <style> of session.html, add:
.quick { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
.quick input { width:96px; padding:7px 9px; background:#111; color:inherit; border:1px solid #333; border-radius:8px; }
.quick button { padding:7px 11px; background:var(--accent,#ff7a18); color:#000; border:none; border-radius:8px; font-weight:600; }
button.mini { background:transparent; border:none; color:#888; cursor:pointer; padding:0 4px; }
- Step 5: Verify manually
With the app running and a live session, open /session:
-
Log a stack via
qStack→ sparkline + net update without a chat call. -
Add a buy-in via
qBuyin→ "bought in" total rises. -
Enter a cash-out via
qCashout→ session net updates. -
Click ✎ on a villain, rename it → name changes after refresh. Confirm via
curl -s localhost:7078/session/data. -
Step 6: Commit
git add lyra/web/static/session.html
git commit -m "feat: HUD quick-capture (stack/buyin/cashout) + villain rename"
Task 7: iOS-PWA bottom safe-area gap fix
Files:
- Modify:
lyra/web/static/style.css(bottom nav / container safe-area)
Interfaces: none (visual fix). The empty band below the nav icons is the home-indicator inset not being consumed by #tabbar.
- Step 1: Load the iOS-PWA skill
Invoke the building-ios-pwas skill and follow its guidance for safe-area / 100dvh handling before editing. The current #tabbar (style.css:921-952) applies env(safe-area-inset-left/right) and padding-bottom: 6px, but does not add env(safe-area-inset-bottom) — the likely cause.
- Step 2: Apply the safe-area fix
In lyra/web/static/style.css, in the mobile #tabbar rule (style.css:921-929), change the bottom padding to consume the inset, and ensure the bar is pinned:
#tabbar {
/* …existing flex/border rules… */
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding-bottom: calc(6px + env(safe-area-inset-bottom));
}
And ensure the chat scroll container reserves space for the bar so content isn't hidden behind it (match the container selector used at style.css:836-852):
@media (max-width: 768px) {
#messages {
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
}
- Step 3: Verify on device
Open the PWA (Add to Home Screen) on iPhone:
-
The empty band below the icons is gone; the nav sits flush above the home indicator.
-
The stack quick-capture box (Task 5) sits directly above the nav.
-
Open the keyboard:
body.kbstill hides the tabbar (style.css:952) and the input pins to the keyboard — confirm no regression. -
If the gap persists or content clips, follow the
building-ios-pwasskill's100dvh/visualViewportguidance and iterate. -
Step 4: Commit
git add lyra/web/static/style.css
git commit -m "fix: consume iOS home-indicator safe-area inset under bottom nav"
Self-Review
Spec coverage:
- Complete API surface (create/update/delete per entity) → Tasks 2 (stack/buyin/start), 3 (hands), 4 (reads/players); existing PATCH/DELETE session + entry routes retained.
- Single documented/versioned tool-API contract → Task 1 (
poker_contract.py,CONTRACT_VERSION) + conformance tests (Tasks 1, 4). - Human UI to log + edit/correct → Tasks 5 (chat 2nd box), 6 (HUD quick inputs + villain rename + existing edit form/delete).
- Pure capture never touches LLM → all capture goes through REST endpoints (Tasks 2–6); verified in manual steps (no chat bubble).
- 2nd input box + PWA fix → Tasks 5, 7.
- Non-goals respected: no classifier/prompts, no MI50 tool enablement, no MCP, buy-in stays scalar (
add_buyinincrementsbuy_in_total).
Placeholder scan: All code steps contain complete code. The one "locate the block" instruction (Task 6 Step 3, villain rename) provides the exact button snippet and names the id-field ambiguity to resolve by reading the file — not a placeholder, a grounded splice.
Type consistency: poker_contract.OPERATIONS shape is consistent across Tasks 1 and 4; REST paths in the contract (/session/stack, /session/buyin, /session, /session/hand, /hand/{hand_id}, /session/read, /player/{player_id}, /session/{session_id}) match the routes added in Tasks 2–4 exactly; update_hand/update_player signatures match their callers; response shapes ({ok, stack}, {ok, buy_in_total}, {ok, id}, {ok, hand}, {ok, player}) are used consistently in tests and routes.
Open implementation note: Task 6 Step 3 requires reading session.html's villain render to confirm the player id field name (v.id vs v.player_id) before splicing the rename button.