Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 3d1ce1f45c Keep the table above the action bar on wide screens
The bar publishes its height so the layout can stay clear of it. Only the phone and
landscape rules ever read it. The wide layout sized the felt from its own width -- a
box 72% as tall as it is wide -- and so had no idea what was underneath it.

That was survivable while the bar was a constant. It isn't: it grows from 117px to
172px the moment a hand needs a side pot, which is exactly the hand where the dealer
most needs to see who is still in. Measured with two side pots, six seats, the bottom
seat sat this far behind the bar:

  1280x900    22px      1600x1000   54px
  768x1024    23px      1024x768    clear
  360x640     39px      390x844     clear

The first attempt at this didn't work and the failure is worth recording: the wrap was
a padding-bottom: 72% box, and percentage padding resolves against the containing
block's width rather than the element's own. Capping the width narrowed the felt from
822px to 746px and left it exactly 617px tall, so the seat stayed where it was. It is
an aspect-ratio box now, and the shape is stated once as --feltAspect so the height
and the cap both derive from it.

  1280x900   felt 857x617 -> 746x537   seat 22px behind the bar -> 51px clear
  1600x1000       ...760 -> 885x637              54px behind -> 59px clear
  768x1024   felt 689x689 -> 603x603              23px behind -> 56px clear

Six-seat tables at 1280x900, 1600x1000, 1024x768 and 768x1024 all go from one covered
seat to a clean audit.

Not applied below 700px. The narrow layout already had an answer -- it reserves the
bar's height as scroll room under the table, and a phone is a thing you scroll -- and
capping there was measured and rejected: at 360x640 it took the felt from 321px to
226px, which fixed the six-seat ring and broke the nine-seat one, two covered seats
traded for two seats whose centre landed on a neighbour.

Landscape phones opt out too. They set their own height from the bar already and are
sitting on their 7rem floor; 844x390 and 667x375 still have the bottom seat in the
bar, and nothing about a 112px-tall table with 73px seats can fix that.

The seat overhang is a constant rather than something measured off the layout it is
about to change -- a seat is ~73px tall at every width -- so this settles in one pass
instead of asking a different question each time it shrinks the felt.

172 tests still passing; no console errors; contrast clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:35:15 -06:00
jheapsandClaude Opus 5 29188d6049 Say ALL IN on the felt
A player with nothing behind had a blank where their stack goes. So did a seat whose
stack nobody had entered. On a table with side pots that is the one distinction the
dealer is working from -- who can still be bet at, and who is already in for
everything they have -- and the felt drew the two states identically.

  Ann  $40  $160     has chips
  Ben  $40           all-in for 40
  Cal  $40  $160     has chips
  Dee  $12           all-in for 12

The action bar knew: it had already built "Main pot $48, Ann/Ben/Cal/Dee" and
"Side pot 1 $84, Ann/Ben/Cal". The table it sits under didn't say a word.

Betting.isAllIn is the same stack test nextToAct uses to pass over a player, which is
the point: the seat the felt marks is exactly the seat the dealer will never be asked
to act on again. There's a test asserting that equivalence rather than restating the
condition.

The second half of the condition matters. A seat with an empty stack that has put
nothing in is a busted player who shouldn't have been dealt in, not a player who
moved all-in, so it isn't marked.

"ALL IN" in gold where the stack was, and a warmer border on the seat -- deliberately
quieter than the acting-seat ring, because all-in is a standing fact about a player
rather than a request for attention.

9 tests (172 total). They fail on a naive condition: dropping the committed-chips and
folded checks fails 4 of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:34:53 -06:00
5 changed files with 199 additions and 4 deletions
+48 -3
View File
@@ -308,9 +308,22 @@ button,
#pokerTableWrap {
position: relative;
/* The shape is stated once, and both the height and the cap below are derived from
it. It used to be a `padding-bottom: 72%` box, which has a trap in it: percentage
padding resolves against the *containing block's* width, not the element's own,
so narrowing the wrap left it exactly as tall as before. Capping the width was
therefore no way to shorten the table — measured, after trying it: the felt went
822px wide to 746px and stayed 617px tall, and the bottom seat sat as far into
the action bar as it had. aspect-ratio ties the two together properly. */
--feltAspect: 0.72;
width: 100%;
padding-bottom: 72%;
aspect-ratio: 1 / var(--feltAspect);
margin: 0 auto;
/* Capping the width is how the felt is told to stay above the action bar.
--feltRoomPx is published by actionBar.js with the bar's real height and the
bottom seat's overhang already taken out; the fallback leaves the table its full
size if that ever fails to arrive. */
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
}
@@ -2126,6 +2139,23 @@ body.hasActionBar #holdemSidebar {
margin-top: 0.1rem;
}
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
letters and a warm colour separate it from the grey money above it at a glance —
which is the whole job, because the dealer is scanning the ring for who can still
be bet at. */
.seatPuckStack.seatPuckAllIn {
color: #ffcf6b;
font-weight: 700;
letter-spacing: 0.06em;
}
/* And the seat itself carries a hint of it, so the mark survives being read out of
the corner of an eye. Deliberately quieter than .seatActing — all-in is a standing
fact about a player, not a request for the dealer's attention. */
.seat.seatAllIn {
border-color: rgba(255, 207, 107, 0.45);
}
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
the seat next to it. These step the size down so every digit stays on its own
@@ -2504,6 +2534,16 @@ body.hasActionBar #toastHost {
}
@media (max-width: 700px) {
/* The narrow layout already had an answer for the bar: it reserves the bar's height
as scroll room below the table, and a phone is a thing you scroll. Capping the
felt here as well was measured and rejected — at 360x640 it took the table from
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
covered seats traded for two seats whose centre landed on a neighbour. The cap is
for the wide layout, which never consulted the bar's height at all. */
#pokerTableWrap {
max-width: none;
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
.potWizardActions {
flex-direction: column-reverse;
@@ -2519,8 +2559,9 @@ body.hasActionBar #toastHost {
grid-template-columns: 1fr;
}
/* One column, so the table has the full width and can afford to be round. */
#pokerTableWrap {
padding-bottom: 100%;
--feltAspect: 1;
}
.seat {
@@ -2845,7 +2886,11 @@ body.hasActionBar #toastHost {
width: 100%;
height: var(--feltRoom);
min-height: 7rem;
padding-bottom: 0;
/* This branch sets its own height outright, so neither the aspect ratio nor the
cap derived from it has anything to say here — and the cap would only narrow
a table that is already as short as the screen allows. */
aspect-ratio: auto;
max-width: none;
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
@@ -29,6 +29,7 @@ const ActionBar = {
const visible = !host.hidden;
const height = visible ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
this.publishFeltRoom(height);
};
if (typeof ResizeObserver === "function") {
@@ -40,6 +41,36 @@ const ActionBar = {
publish();
},
// Half a seat, the gap left under the lowest one, and the smallest felt worth
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
// table-sized — so the overhang is a constant rather than something read back off
// the layout this is about to change. Measuring it would mean shrinking the felt,
// moving the seats, and getting a different answer on the next pass.
SEAT_OVERHANG_PX: 37,
FELT_GAP_PX: 8,
FELT_MIN_PX: 220,
// How tall the felt can be and still leave the whole ring of seats above the bar.
//
// Publishing the bar's height was only half the job. The wide layout never used it:
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
// 117px to 172px the moment there's a side pot to show, which is exactly when the
// dealer most needs to see who is still in. Measured with two side pots, the bottom
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
//
// The wrap's offset is taken in document space rather than viewport space, so
// scrolling the page doesn't resize the table under the dealer's hands.
publishFeltRoom(barHeight) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return;
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
const room = window.innerHeight - barHeight - topInDocument -
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
document.documentElement.style.setProperty(
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
// still holds during setup, because a seated player is a live seat whether or not
// the betting has been opened. An empty table stays uncluttered.
@@ -117,6 +117,20 @@ const Betting = {
return null;
},
// A player still in the hand with chips in the pot and nothing left behind. This is
// the same stack test nextToAct uses to pass over them, which is the point: the
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
// act on again.
//
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
// anything in is a busted player who shouldn't have been dealt in, not a player
// who moved all-in, and calling them all-in on the felt would be a lie.
isAllIn(state, player) {
if (!player || !player.inHand || player.folded) return false;
return this.handTotal(player) > this.EPSILON_CHIPS &&
this.stackOf(state, player) <= this.EPSILON_CHIPS;
},
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
@@ -279,11 +279,18 @@ const Render = {
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
// A player with nothing behind used to leave this line blank, so the felt drew
// an all-in exactly like a seat whose stack nobody had entered. On a table with
// side pots that's the one distinction the dealer is working from — who can
// still be bet at, and who is already committed for everything they have.
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
const allIn = Betting.isAllIn(this.state, player);
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
stackEl.classList.toggle("seatPuckAllIn", allIn);
this.fitAmount(stackEl);
node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
+98
View File
@@ -0,0 +1,98 @@
// Who the felt marks "ALL IN".
//
// The mark's whole value is that a dealer can scan the ring and see who can still be
// bet at, so the test that matters most is the last one: the seats it marks are
// exactly the seats the betting round refuses to ask for another chip.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("a player who moved all-in is marked", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
assert.equal(state.seats[3].stack, 0);
});
test("a player with chips behind is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("a busted player who was dealt in but has put nothing in is not", () => {
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("a folded player with an empty stack is not", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[1].committed = 5;
state.seats[1].stack = 0;
state.seats[1].folded = true;
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("an empty chair is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
});
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
state.seats[2].committed = 10;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("the seats marked are exactly the ones the action passes over", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[1].stack = 8;
state.seats[3].stack = 30;
Betting.beginHand(state);
// Everyone in, two of them for less than the others can cover.
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
const marked = state.seats
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
.filter(index => index !== null);
const skipped = state.seats
.map((player, index) =>
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
.filter(index => index !== null);
assert.deepEqual(marked, skipped);
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
});
test("nobody is marked once the next hand starts", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
// The short stack got there by winning nothing; give them chips back the way a
// dealer would before the next deal.
HoldemState.startNextHand(state);
state.seats[2].stack = 50;
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
});
test("marking nobody doesn't move a chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
const before = chipsOnTable(state);
state.seats.forEach(player => Betting.isAllIn(state, player));
assert.equal(chipsOnTable(state), before);
});