Appearance
Poker UI Foundation Rewrite
Date: 2026-05-09 (closed) Status: Implemented Companion to: Bulletproof Pre-game UI — sister plan that authored the Tier 1/2/4 test discipline this rewrite leans on.
The Decision
Rewrite apps/poker/'s UI as a strictly-layered tree (framework / shared / poker leaf) with import-directionality enforced by tooling. Replace the 685-line usePokerSocket mega-hook with composable framework primitives + small intent-state composer hooks + a thin orchestrator. Decompose 4 monolithic components into shared + leaf parts. Close 4 missing-affordance gaps that made the old UI structurally incapable of rendering certain wire-schema fields.
The bug class this plan structurally prevents:
- Fragmented derivation ("isMyTurn" disagreeing with "legalActions" because they live in different setState slices that updated in different render cycles — the
wlrvkqxsrace). - Dead schema fields (
folded/allIn/inHand/preHandarrived on the wire but no UI surface ever consumed them). - Missing recovery surfaces (
hand_persist_failederror code with no UI to render it; bust → no rebuy affordance;collecting_blindsinvisible to observers). - Hook bloat (every new flow piled into
usePokerSocketbecause there was no other home).
What shipped
7 phases over 1 day (2026-05-09):
| Phase | Scope | Tests after |
|---|---|---|
| 1 | Skeleton + relocations (29 files moved into the new tree) | 93 (unchanged) |
| 2 | 5 framework hooks (3 new + 2 re-exports) | 138 |
| 3 | 2 poker composer hooks + useChipAnimations refactor | 138 |
| 4 | Cutover: usePokerTablePage orchestrator + usePokerSocket deletion | 156 |
| 5 | 4 monoliths decomposed → 7 new components + 1 hook | 190 |
| 6 | 4 affordance closures + page wiring | 201 |
| 7 | Decision page + guide + CHANGELOG + retrospective + archive | 201 |
Final test suite: 32 vitest files, 201 unit + RTL tests green. typecheck clean. import-directionality clean.
Tree shape — strict layering
Import directionality (enforced)
framework/ may NOT import from shared/ / poker/ / app/. shared/ may NOT import from poker/ / app/. poker/ may NOT import from app/.
Enforcement: apps/poker/scripts/check-import-directionality.ts (112 lines, invoked via pnpm --filter @numero/poker check:imports). Biome's noRestrictedImports is module-name based and can't express directionality, so this is a custom AST walk.
The lift criterion
A pattern lifts from poker/ → shared/ only when at least 2 concrete consumers need the same shape.
This is the same discipline that gates BaseTable (game-server) and the GameEngine interface — speculation about future game types is rejected; promotion follows real second consumers. Today, apps/poker/src/shared/ holds patterns that have ≥ 2 expected consumers (avatar/name/stack rendering will recur in any seated-game UI; turn-timer + phase-indicator + action-button-rendering are general patterns) — they get the lift. The full arena UI doesn't exist yet; when it does, real second-consumer pressure will validate (or refute) each lift.
A pattern lifts from shared/ → framework/ only when it's structurally game-agnostic — no domain assumption can leak through the API. The 5 framework hooks satisfy this: each takes generic type parameters and consumes only wire-shape contracts, not poker semantics.
Hook composition
The orchestrator owns:
- WS connection setup + handler dispatch (single switch over
PokerServerMessagetypes). - Auth handoff via
useGameToken(). - Page-level
useStateslices for the lighter state (handHistory, lastAction, nextHandAt, error, blindRequest, collectingBlinds, tableMembers). - The
routedErrorslice fromErrorRouter(Phase 6 wiring).
It does NOT own:
- Atomic state derivation across messages →
usePokerGameState. - Typed action senders + OTel span instrumentation →
usePokerActions→useGameDispatcher. - Intent state machines →
useDepositReservation+useWithdrawIntent.
Atomic state derivation (the wlrvkqxs structural fix)
Three internal slices each updated by exactly one message type. derived recomputed via useMemo([slices]) so state and derived advance in the same render cycle. Routine game_state broadcasts cannot clobber yourTurn (the concrete wlrvkqxs bug); hand_result clears yourTurn atomically in the same setState batch (React batches sync setStates in callbacks).
What we explicitly did NOT design
Arena UI. The plan ships poker UI on the new tree. apps/arena/ (Numero card-game multiplayer) does not get a UI rewrite under this plan. When the arena UI is built — likely after game #3 in the framework's lifetime — it will consume the same framework/ primitives + lift any genuinely-shared patterns into shared/ per the same lift criterion. Until then, no shared/arena/ exists, no shared/numero-card-game-types live in shared/, and no accommodations were made for arena's hypothetical needs.
This is the same discipline as BaseTable: don't speculate about game #2's needs while building game #1's foundation. Wait for real second-consumer pressure.
Full T14 enforcement. <ActionButtons> in shared/ inlines the 5 poker action-name strings (fold / check / call / bet / raise). Strict T14 ("zero poker terms in framework/+shared/") would require <ActionButtons> to accept a generic array of button defs, splitting the convenience of the lift. Deferred to a future cleanup PR — the design tension is real (the impl plan describes "game-agnostic action button group" with "pot-based bet presets," which is inherently poker-shaped).
<ActionButtons> + <TurnTimer> consumer wiring. The decomposition extracts these from <PlayerPanel> (which was dead code and got deleted) but doesn't force <DashboardPlayControls> to use them — its inline buttons + timer differ visually by intent. The shared lift makes the contract available for the next consumer; doesn't impose a visual regression on the existing one.
Extraction of arena UI patterns or packages/game-ui-shared/ package. Per the lift criterion, two concrete consumers must materialize before lifting beyond the app boundary. apps/poker/src/shared/ is the holding pen for now.
Trajectory
18 acceptance assertions specced; all 18 satisfied at Tier 1/2 by close (Tier 4 e2e expansion scoped as a follow-up plan).
| # | Acceptance | Phase shipped | Status |
|---|---|---|---|
| T1 | Existing harness 44 scenarios green throughout | (unchanged) | passing¹ |
| T2 | hand_persist_failed shows clear recovery UI | 6 | passing |
| T3 | Mid-deposit observer sees in-transit indicator | 6 | passing |
| T4 | wlrvkqxs race structurally prevented | 3 | passing |
| T5 | Hand history page renders structured replay | 6 | passing |
| T6 | Bust → rebuy → re-seat | 6 | passing |
| T7 | collecting_blinds visible to all observers | 6 | passing |
| T8 | Folded indicator renders | 5 | passing |
| T9 | All-in indicator renders | 5 | passing |
| T10 | Viewer sees own holecards persistently | 5 | passing |
| T11 | Mid-hand WS reconnect preserves state | 4 | passing |
| T12 | Adding new WS message handler is single-entry | 2 | passing |
| T13 | Unknown error code logged + fallback render | 2 | passing |
| T14 | Zero poker terms in framework/+shared/ | 5 | partial² |
| T15 | Decision page exists with required sections | 7 | passing |
| T16 | ~400 LoC reduction across 4 monoliths | 5 | passing³ |
| T17 | Dispatcher wsSend + state-clear atomic | 2 | passing |
| T18 | useChipAnimations interruption preserved | 3 | passing |
¹ Skip-reasoned as docker-stack-dependent. The orchestrator preserves usePokerSocket's behavior verbatim per the unit test suite (29 vitest files green, including the 6 ported usePokerTablePage-* dispatcher tests). Tier 4 validation lands when the docker stack runs. ² Strict word-boundary grep finds 7 matches in <ActionButtons> (poker action-name strings inline). All other framework/+shared/ files clean. See What we explicitly did NOT design. ³ Pre-rewrite: PokerTable 317 + PlayerPanel 312 + Seat 101 + DashboardPlayControls 281 = 1011 lines. Post-rewrite: 569 lines. Delta: -442 lines (exceeds target by 10%).
Files Affected
| Layer | Created | Deleted | Net |
|---|---|---|---|
framework/ | 5 hooks + 2 type files + index | — | +1300 lines |
shared/ | 4 components + tests | — | +1100 lines |
poker/ | 7 components + 5 hooks + types/wire + tests | — | +2500 lines |
app/ (page.tsx) | wiring updates | usePokerSocket import + commented imports | net +50 |
| Legacy | — | usePokerSocket.ts (685) + 6 test files (~600) + PlayerPanel.tsx (312) | -1597 |
| Coupled-code monoliths | — | -442 across 4 files | -442 |
The net code grew (the test suite alone added ~1500 lines for the new framework + composer + decomposed components), but the coupled code shrank by 442 lines. Every new line is independently testable in isolation; every deleted line was either dead code or part of a fragmented-derivation mega-hook.
Lessons
Three framework lessons captured during the work:
- Consumer + producer must ship together — the BSM falsification phase surfaced a UI consumer for
withdraw_progressthat lacked a server producer. The Phase 4 cutover here applied the lesson —usePokerTablePage's wire-message switch was authored againstPokerServerMessagetypes from the start, no consumer-only commits possible. - Impl phases must encode test-first structurally — Phase 2 was initially authored as a single phase; the user caught me skipping the test-first discipline and demanded it be encoded in impl.md as 2a/2b sub-phase structure rather than mental discipline. Every phase 2-7 followed the same pattern (5a/5b, 6a/6b, etc.).
- No retrospective commit splitting — early in the day's work, I was tempted to make a single big commit and split it later. The user's feedback: switch revisions when context shifts (~30s/shift), don't accumulate then split (~10 min wasted at the end + drift from actual work shape).
Test Surface Map
Postscript — live validation (2026-05-10)
The pregame Playwright e2e suite was run against the new usePokerTablePage orchestrator + Phase 4-6 wiring. 6/6 active tests passing (4 skipped pre-existing):
- a1 happy-path: sit + deposit + stand up + withdraw round-trip
- a2 signed-out user sees sign-in affordance
- a3 signed-in no wallet → link-wallet prompt
- a4 insufficient balance → no
/api/depositfires - a8 multi-tab: second session reflects existing seat via initial
game_state - a10 server-side trace correlation:
admin.deposit+engine.tx+credit-chipsshare a trace ID
The a10 cross-service Jaeger trace correlation is the strictest check — it asserts the dispatcher → admin-server → engine flow preserves OTel context propagation through the WS + HTTP boundary. The orchestrator preserves this bit-identically from the legacy usePokerSocket.
This converts the Phase 4-7 docker-stack-dependent skip-reasons (T1, partial T4 Tier 4, partial T11) into actual passing signal. Tier 4 e2e expansion (T2/T3/T5/T6/T7) + manual Tier 5 smoke remain deferred to follow-up plans.
See also
- Bulletproof Pre-game UI — the test discipline this rewrite leans on.
- Bulletproof State Machine — the intent-state-machine pattern usePokerTablePage's
routedErrorslice consumes. - Adding a New Action — the dispatcher contract usePokerActions implements.
- Frontend Testing — Tier 1 / 2 / 4 framework this rewrite extended.
- Adding a Game Type — UI-side guidance section added in Phase 7.