Skip to content

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 wlrvkqxs race).
  • Dead schema fields (folded / allIn / inHand / preHand arrived on the wire but no UI surface ever consumed them).
  • Missing recovery surfaces (hand_persist_failed error code with no UI to render it; bust → no rebuy affordance; collecting_blinds invisible to observers).
  • Hook bloat (every new flow piled into usePokerSocket because there was no other home).

What shipped ​

7 phases over 1 day (2026-05-09):

PhaseScopeTests after
1Skeleton + relocations (29 files moved into the new tree)93 (unchanged)
25 framework hooks (3 new + 2 re-exports)138
32 poker composer hooks + useChipAnimations refactor138
4Cutover: usePokerTablePage orchestrator + usePokerSocket deletion156
54 monoliths decomposed → 7 new components + 1 hook190
64 affordance closures + page wiring201
7Decision page + guide + CHANGELOG + retrospective + archive201

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 PokerServerMessage types).
  • Auth handoff via useGameToken().
  • Page-level useState slices for the lighter state (handHistory, lastAction, nextHandAt, error, blindRequest, collectingBlinds, tableMembers).
  • The routedError slice from ErrorRouter (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).

#AcceptancePhase shippedStatus
T1Existing harness 44 scenarios green throughout(unchanged)passing¹
T2hand_persist_failed shows clear recovery UI6passing
T3Mid-deposit observer sees in-transit indicator6passing
T4wlrvkqxs race structurally prevented3passing
T5Hand history page renders structured replay6passing
T6Bust → rebuy → re-seat6passing
T7collecting_blinds visible to all observers6passing
T8Folded indicator renders5passing
T9All-in indicator renders5passing
T10Viewer sees own holecards persistently5passing
T11Mid-hand WS reconnect preserves state4passing
T12Adding new WS message handler is single-entry2passing
T13Unknown error code logged + fallback render2passing
T14Zero poker terms in framework/+shared/5partial²
T15Decision page exists with required sections7passing
T16~400 LoC reduction across 4 monoliths5passing³
T17Dispatcher wsSend + state-clear atomic2passing
T18useChipAnimations interruption preserved3passing

¹ 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 ​

LayerCreatedDeletedNet
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 updatesusePokerSocket import + commented importsnet +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_progress that lacked a server producer. The Phase 4 cutover here applied the lesson — usePokerTablePage's wire-message switch was authored against PokerServerMessage types 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/deposit fires
  • a8 multi-tab: second session reflects existing seat via initial game_state
  • a10 server-side trace correlation: admin.deposit + engine.tx + credit-chips share 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 ​