diff --git a/docs/RECORDER.md b/docs/RECORDER.md
new file mode 100644
index 0000000..5f24f36
--- /dev/null
+++ b/docs/RECORDER.md
@@ -0,0 +1,148 @@
+# Hand recorder — design note
+
+A tap-to-build hand recorder. The point isn't "nicer input" — it's **correctness by
+construction**: every tap writes a known action into a known slot, so there's no parse
+step that can be wrong. It sidesteps the whole class of LLM-parse replay bugs. The text
+parser stays for importing the backlog (old notes, Trilium, ChatGPT history); the recorder
+is clean capture going forward.
+
+Output is the canonical structured shape in [HAND_HISTORY.md](HAND_HISTORY.md) — so it
+drops straight into the DB and the existing replay viewer, and flows to RTO unchanged.
+
+## Principles
+
+1. **Correctness by construction** — the UI only lets you build valid hands; the emitter
+ produces the contract shape; the server `normalize_structured()` is the final guarantee.
+2. **Reusable module, mount-agnostic.** Decision: overlay first, swap to standalone if the
+ overlay fights the chat page — *reusing the code either way*. So the recorder is a
+ self-contained module mounted into a container element, with the **emit logic kept pure
+ (no DOM)**. Moving overlay → standalone is a re-mount, not a rewrite.
+3. **Don't reinvent what Lyra knows.** Pre-fill from live session state.
+
+## Architecture
+
+```
+lyra/web/static/recorder.js # the module: state machine + DOM shell + buildStructured()
+lyra/web/static/recorder.css # scoped styles (full-screen table + keypad)
+```
+
+- `Recorder.mount(containerEl, { sessionId, onSave, onClose })` — instantiates into any
+ container. In V1 the container is a full-screen overlay `
` inside `index.html`
+ (chat/session page stays mounted underneath — a flip-over, not a route change). If that
+ proves janky, the *same module* mounts into `recorder.html` with zero logic changes.
+- **Pure core, separable from DOM:** `buildStructured(state) -> structuredDict`. No element
+ access — takes the in-memory state, returns the contract object. This is the testable,
+ reusable heart; the DOM shell only reads/writes `state` and calls `buildStructured` on save.
+
+## Pre-fill from live state (the "it already knows" feel)
+
+Source: `GET /session/data` (→ `poker.hud`). Today it gives us:
+- `session`: `venue`, `stakes`, `game`, `format`, `is_live`
+- `stack.current` → hero's starting stack for the hand
+- `villains[]`: `name`, `category`, `tendencies`, `last_note` (the players read this session)
+
+Derived in the recorder:
+- **Blinds** parsed from `stakes` ("1/3" → SB 1, BB 3) → auto-seed the `post` actions.
+- **Hero stack** from `stack.current`.
+
+**Two gaps to close as part of the build** (flagged, not yet done):
+1. **Seats aren't in the HUD bundle.** `player_reads` *has* a `seat` column, but
+ `_session_villains()` doesn't select it — so we can name the villains but not place them.
+ Fix: add `seat` (latest read per player) to the villains payload, then auto-seat them.
+ Until then, V1 seats known villains in read-order and you assign positions by tapping.
+2. **Hero position isn't tracked live** (button/seat moves every hand) — so `hero_pos` is a
+ per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it.
+
+## In-memory state model
+
+```js
+state = {
+ meta: { game, stakes, venue, sessionId }, // from /session/data
+ blinds: { sb, bb }, // parsed from stakes
+ heroPos: "BTN", // tapped per hand
+ seats: [ { pos, name, stack, cards: null, in: true } ], // incl. hero seat
+ street: "preflop", // street currently being entered
+ board: { flop: [], turn: [], river: [] },
+ actions:[ { street, pos, action, amount } ], // appended as you tap
+ result: { pot: null, heroNet: null, summary: "" }
+}
+```
+
+`buildStructured(state)` →
+
+| contract field | from |
+|---|---|
+| `hero_pos` | `state.heroPos` |
+| `hero_cards` | the hero seat's `cards` |
+| `players[]` | `state.seats` (`{pos,stack,name,cards}`; emitter doesn't set `hero`/version — server normalize does) |
+| `actions[]` | `state.actions`, with a `{street, board}` reveal entry spliced in at each street boundary from `state.board` |
+| `board` | `flop + turn + river` concatenated |
+| `result` | `state.result` |
+
+Client builds best-effort; **`store_hand_history()` → `normalize_structured()` is the
+authority** (canonical cards, hero sync, `schema_version`, `completeness`). Keeps the
+client dumb and the contract enforced in one place.
+
+## Persistence
+
+New endpoint (small, part of the build):
+```
+POST /hands body: { structured, session_id?, tag?, lesson? }
+ -> store_hand_history(structured, ...) -> { id }
+```
+On save: POST, then hand off to the existing viewer `/hand/{id}` to replay — which doubles
+as the correctness check (what you tapped is exactly what replays).
+
+## Scope
+
+**V1 — core loop (chosen).** Seats + cards + per-street actions emitting valid structured
+JSON. Manual street advance (a "next street" button + board entry), free bet-size entry
+(type the number). Proves capture → store → replay end to end on the locked schema.
+
+### Card entry (V1)
+
+Contextual: tap a card slot (hero card, board square, "they showed") → a compact picker
+pops at that slot. One picker holds **4 color-coded suits + 13 ranks + `x` + unknown-card**.
+Whichever you tap first sets the flow for that card — no mode switch:
+
+- **Suit first → it locks** (stays lit). Each subsequent rank tap places `rank+lockedSuit`
+ and auto-advances. Flush flop = `♥ T 8 5` (4 taps); suited hole = `♥ A K` (3 taps).
+- **Rank first → card is pending a suit**; the next tap must be a suit (or `x`). Best for
+ rainbow/mixed. Locked suit stays in effect until a different suit is tapped.
+- **`x`** = unknown suit → stores e.g. `Ax`; flips `completeness.cards` false so RTO skips
+ suit-dependent math. **Unknown-card** button = a villain card never shown (`x`).
+
+No typing — lowercase tokens are the internal/contract format only; the player only ever
+taps symbols. State: `lockedSuit` (nullable) + the active slot; auto-advance on complete.
+
+**V2 — the smart keypad.** The contextual state machine layered on top: tracks whose turn
+it is and the current bet, offers only legal actions (check vs call; bet/raise reveal size
+presets ½/¾/pot/+1bb), auto-advances the street when action closes, tap-a-seat to set the
+actor, one-tap "they showed [cards]" at showdown. ~6–10 taps, no typing. Built on the same
+`state` + `buildStructured`, so V1's emitter doesn't change — V2 just drives `state` smarter.
+
+## Layout sketch (full-screen overlay)
+
+```
+┌───────────────────────────── Record hand ─────────────── ✕ ┐
+│ (CO) (BTN) │
+│ (HJ) ◯ oval table ◯ (SB) │
+│ (MP) (UTG) (BB·hero) │
+│ board: [ 7d ][ 2c ][ 5h ] pot: 40 │
+├──────────────────────────────────────────────────────────────┤
+│ acting: BB [ fold ][ check ][ call ][ bet ][ raise ] │
+│ amount: [ 15 ] [ ½ ][ ¾ ][ pot ][ +1bb ] (V2) │
+│ [ ◀ prev street ] [ next street ▶ ] [ they showed… ] │
+├──────────────────────────────────────────────────────────────┤
+│ preflop: BTN raise 15 · BB call [ save & replay ] │
+└──────────────────────────────────────────────────────────────┘
+```
+
+## Build order
+
+1. `POST /hands` endpoint + add `seat` to the villains payload (server, small).
+2. `recorder.js` skeleton: `mount()`, `state`, `buildStructured()` (pure).
+3. Overlay shell in `index.html` (open button in session/cash mode) + `recorder.css`.
+4. V1 capture flow → save → replay. Validate a real hand round-trips identically.
+5. V2 smart keypad on top.
+```