Files
project-lyra/docs/superpowers/plans/2026-06-28-poker-logging-service.md
T
serversdown a2835500bc docs: implementation plan for poker logging service (sub-project 1)
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
2026-06-28 23:27:58 +00:00

721 lines
31 KiB
Markdown
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.
# 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; ruff `line-length = 100`, `target-version = "py311"`.
- **`lyra/web/static/index.html` uses 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.py` is the single source of truth: REST routes and `tools.py` specs must agree with it (enforced by a conformance test).
- Web app runs via `lyra-web` (uvicorn) on `0.0.0.0:7078`. DB path from `LYRA_DB_PATH` (default `data/lyra.db`, WAL).
- Test idiom: fixture sets `LYRA_DB_PATH` to a `tmp_path` file, stubs `llm.embed` (and `llm.complete` where needed), then `importlib.reload(memory)` **then** `importlib.reload(poker)` (order matters), then `importlib.reload(server)` for endpoint tests. Run with `.venv/bin/pytest` (or `uv run pytest`).
- Existing store facts to respect: `start_session(...)` uses `fmt=` (column is `format`); `add_buyin` returns a float total; `log_stack` returns the `stack_state` dict `{current, buy_in, net}`; `end_session(cash_out, ...)` takes `cash_out` first; `hud()` returns `None` when 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)``ctx` is 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]` and `CONTRACT_VERSION: int`. Each op value: `{"required": tuple[str,...], "llm_tool": str | None, "rest": tuple[str, str] | None}` where `rest` is `(METHOD, PATH)` with PATH exactly matching the FastAPI route template.
- [ ] **Step 1: Write the contract module**
`lyra/poker_contract.py`:
```python
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`:
```python
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**
```bash
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 inside `create_app`, near the existing `PATCH /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`:
```python
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:
```python
@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**
```bash
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` (add `update_hand` near `log_hand` at 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`:
```python
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_hand` to the store**
In `lyra/poker.py`, immediately after `log_hand` (poker.py:558), add:
```python
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:
```python
@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**
```bash
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` (add `update_player` near `upsert_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`:
```python
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`:
```python
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_player` to the store**
In `lyra/poker.py`, immediately after `upsert_player` (find it near poker.py:1010), add:
```python
_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:
```python
@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**
```bash
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). Reads `currentSession` and 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.**
```html
<!-- 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):
```javascript
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:
```css
#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.tool` shows `stack.current == 350`.
- [ ] **Step 5: Commit**
```bash
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 globals `curSession`, `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>`:
```javascript
<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:
```javascript
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):
```javascript
<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:
```css
.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**
```bash
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:
```css
#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):
```css
@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.kb` still 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-pwas` skill's `100dvh`/`visualViewport` guidance and iterate.
- [ ] **Step 4: Commit**
```bash
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 26); 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_buyin` increments `buy_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 24 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.