Handle the messy table: people joining, leaving, and arguing about blinds

Home games don't run like a cardroom. People walk up mid-hand, storm off
mid-hand, and pass the button round three times before anyone deals. Probing
each of those found four problems, one of which loses chips.

A player taken out mid-hand took their chips with them

`computeContribution` opened with `if (!player.inHand) return 0`, so the moment
a dealer switched a seat off, everything that seat had already put in vanished
from the pot. Measured: four players in for $2 each, one taken out, pot drops
from $8 to $6. Their $2 was still sitting on the seat record — it just stopped
counting. A dealer following the tool would have paid out $6 of an $8 pot.

Chips already pushed into the middle are dead money. They stay in the pot and go
to whoever wins, whether or not the player is still in the hand, and it is not
theirs to take back. The `inHand` check now guards only the table-wide base-pot
default, which does have to stay tied to being dealt in or every empty chair
would be treated as having anted. The pot wizard counts a departed seat as
folded, so its chips are in the pot but it can never be picked as a winner.

Passing the button before the cards come out

Moving the button left the posted blinds where they were while everything
positional started reading off the new spot, so the money and the blind
positions silently disagreed. While nothing but the blinds are in, moving the
button now refunds them and re-posts from where it lands — pass it round six
times and the chip count is unchanged. Once anybody has acted the blinds are
settled and it stops offering.

Naming the blinds by hand

Blinds were purely positional with no way to overrule them. The seat menu now
offers "Make small blind" / "Make big blind" while only the blinds are in, and a
badge with a ring marks a blind that was named rather than dealt. Naming one
pins the other where it is. It's refused if it would put both blinds on one
player or post to somebody who isn't dealt in, and it lasts one hand — carrying
it forward would freeze the blinds on two people for the night.

The first hand, and a table that empties out

A fresh table puts the button on seat 1 whether or not anyone is sitting there,
and a table can empty out around it. The button is moved onto a real player
before a hand is dealt.

Joining mid-hand

A newcomer was added straight into the live seats and would be asked to act in a
hand they hadn't been paying into. They now sit the hand out and are a full
player from the next one.

Nothing is covered on a phone any more

Swept 13 viewport sizes with elementFromPoint, five points per control, on the
table and every overlay. Before: the action bar covered the bottom seats on
short screens (62px of felt at 360x640), the toast covered up to six seats at
once — Undo button included, so a tap meant for a player hit "Undo" — and in
landscape the table came out 765px tall in a 390px window, putting six of nine
seats off-screen entirely.

- the action bar's real height is published as `--actionBarHeight` and drives
  both the page's bottom padding and the toast, replacing a hardcoded 9rem that
  was wrong the moment the raise pad opened
- landscape sizes the felt from the height that's left, not from width. The
  first attempt did nothing: `padding-bottom` is a percentage of the containing
  block's *width*, so narrowing the wrap left it full height, and a
  `max-width: 900px` rule later in the file was overriding it anyway. It's set
  outright now and lives at the end of the sheet.
- on short screens the toast floats over the action bar's status readout rather
  than over the seats. Nowhere is clear of both; covering something you re-read
  beats covering something you tap.
- headers go compact on short screens, which is where the vertical room came from

13/13 sizes clean, 360x640 through 844x390, portrait and landscape.

Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles
rather than clearing, and `manual` is undefined for positional blinds, so the
hand-picked ring appeared on alternate renders.

18 new tests (76 total, all passing).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-01 03:40:02 -06:00
co-authored by Claude Opus 5
parent 073ba9a252
commit 3d63ba50ee
9 changed files with 603 additions and 16 deletions
+114 -2
View File
@@ -306,8 +306,10 @@ button,
position: relative;
width: 100%;
padding-bottom: 72%;
margin: 0 auto;
}
#pokerTable {
position: absolute;
inset: 6%;
@@ -627,6 +629,13 @@ button,
color: #fff7e6;
}
/* A blind the dealer named by hand rather than one the button handed out. Without
the ring the table just looks like it's put the blinds in the wrong place. */
.blindBadge.blindManual {
outline: 1.5px solid #f2f2f2;
outline-offset: 1px;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
@@ -1955,8 +1964,13 @@ body.hasActionBar #holdemSidebar {
/* Sit above the action bar rather than on top of it, so an Undo never covers the
Fold button the dealer is reaching for. */
/* Sit the toast directly on top of the action bar, whatever height it happens to be.
This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the
moment the raise pad opens — and on a short phone it put the toast, Undo button and
all, straight over the bottom seats of the table, so a tap meant for a player hit
"Undo" instead. */
body.hasActionBar #toastHost {
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px));
}
.toast {
@@ -2287,8 +2301,13 @@ body.hasActionBar #toastHost {
seat looks like it did nothing. Inset the ring by half a puck, and float the
editor up as a bottom sheet over the table. */
@media (max-width: 700px) {
/* The bottom padding has to clear the fixed action bar, plus room for a toast to
sit above it. Without it the table just carries on underneath the bar and the
lower seats of the oval are behind the Fold button — measured at 360x640, where
62px of felt was covered. `--actionBarHeight` is published by actionBar.js and
is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */
#holdemApp {
padding: 1rem 0.75rem 2rem;
padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
}
#holdemHeader {
@@ -2560,3 +2579,96 @@ body.hasActionBar #toastHost {
justify-content: center;
}
}
/* ---------- A phone on its side ----------
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
— so a landscape rule placed any earlier is overridden at equal specificity and
does nothing, which is exactly what happened on the first attempt.
Landscape is where this layout falls apart worst: sized from width, the table came
out 765px tall in a 390px-tall window, putting six of the nine seats below the fold
and the rest behind the action bar. Sizing it from the height that's actually left
makes every seat reachable without scrolling. The table ends up small, but a seat
you have to go hunting for is worse than a seat that's small. */
@media (orientation: landscape) and (max-height: 560px) {
/* Set outright rather than via padding-bottom: a percentage padding resolves
against the containing block's width, not the element's own, so narrowing the
wrap on its own left it full height regardless. */
#pokerTableWrap {
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
width: min(100%, calc(var(--feltRoom) / 0.72));
height: var(--feltRoom);
padding-bottom: 0;
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
table started below the fold. Here it only needs to be a title bar. */
#holdemHeader {
margin-bottom: 0.4rem;
gap: 0.35rem;
}
#holdemHeader h1 {
font-size: 1.05rem;
padding-bottom: 0.15rem;
}
.holdemSubtitle {
display: none;
}
/* There's no vertical room left, but plenty either side of a table this narrow,
so the toast moves out of the middle and sits beside the felt instead of on
top of it. Centred, it was covering up to six seats at once — and it carries
an Undo button, so a tap meant for a player was landing on "Undo". */
body.hasActionBar #toastHost,
#toastHost {
left: auto;
right: 0.5rem;
max-width: 17rem;
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
}
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
.seat {
width: 62px;
min-height: 40px;
}
}
/* ---------- Short phones, upright ----------
A 640px-tall screen spends ~150px on the header before the felt even starts, which
left the bottom of the table sitting behind the action bar until you scrolled. The
header carries no information a dealer needs mid-hand, so it gives the space up. */
@media (max-height: 700px) {
/* Nowhere on a screen this short is the toast clear of both the table and the
action bar, so it overlaps the one that costs least: it sits over the bar's
status line, which is information a dealer can re-read a second later, rather
than over the seats and buttons, which are things they need to hit. Overlapping
a readout beats covering a control. */
body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 0px) - 3.1rem);
}
.toast {
padding: 0.5rem 0.7rem;
font-size: 0.78rem;
}
}
@media (max-width: 700px) and (max-height: 700px) {
#holdemHeader {
margin-bottom: 0.5rem;
gap: 0.4rem;
}
#holdemHeader h1 {
font-size: 1.15rem;
padding-bottom: 0.2rem;
}
.holdemSubtitle {
display: none;
}
}