From cc6a1902bc57d06ae1445ca2461b06aae0b0d2b3 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 04:39:51 -0600
Subject: [PATCH 1/9] Stop the tool from throwing a pot away without saying so
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two ways to lose a hand's money by accident, both found by walking the settle
flow and checking the chip count afterwards.
Next Hand destroyed an unresolved pot silently
Starting the next hand clears every seat's contribution, so money still in the
middle simply vanished. Measured at a four-handed table with $8 in the pot: the
table went from 800 chips to 792, and the toast said "Next hand — button on seat
3. Blinds posted." — not a word about the eight chips that had just left.
It now asks, names the amount, and says what will happen to it. Confirming still
works and the toast says what went. The undo was always there, but a dealer
can't undo something they were never told about.
The wizard's "Close" abandoned a payout that looked finished
After picking winners and confirming, the wizard shows "Final Payouts — Ann $8"
above two buttons: "Start Next Hand" and "Close". Close pays nobody. Under that
heading, with a name and an amount beside it, a dealer taps Close believing the
pot has been pushed — and walks away with the chips still in the middle and the
hand unlogged. The button now says "Cancel — don't pay yet", with a line
underneath spelling out that nothing has been paid.
The warning measures the right thing
The first version counted the pot badge, which was wrong in a way the tests
caught before it shipped: the table-wide base pot is a standing ante for dealers
who don't track stacks, and it re-applies to the next hand rather than being
lost. Quoting it would have put a confirmation in front of those dealers before
every single hand — a nag, on the simplest way to run a table, for money that
wasn't going anywhere.
HoldemState.unresolvedContributions() measures exactly the fields startNextHand
clears: banked contributions, chip counts, and whatever is still in front of
players on this street. Verified both ways in the browser — a base-pot table
showing a $100 pot is not asked at all, a stack-tracking table with $8 live is.
6 tests (115 total): the quoted number equals what actually leaves the table,
mid-street chips are included, a settled hand leaves nothing to warn about, five
settled hands conserve every chip, an empty table has no phantom pot, and a
standing ante is never warned about.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/main.js | 23 +++-
.../wwwroot/js/casino/holdem/potWizard.js | 24 +++-
.../wwwroot/js/casino/holdem/state.js | 32 ++++++
tests/holdem/potSafety.test.js | 103 ++++++++++++++++++
4 files changed, 175 insertions(+), 7 deletions(-)
create mode 100644 tests/holdem/potSafety.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 56c2494..31be248 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -318,15 +318,34 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderAll();
});
- document.getElementById("nextHandBtn").addEventListener("click", () => {
+ document.getElementById("nextHandBtn").addEventListener("click", async () => {
+ // Moving on wipes every seat's contribution, so an unresolved pot is simply
+ // destroyed — measured at a four-handed table, pressing this with $8 in the
+ // middle took the table from 800 chips to 792, and the toast said nothing
+ // about it. Chips only ever leave this tool because a dealer meant them to.
+ const unresolved = HoldemState.unresolvedContributions(state);
+
+ if (unresolved > Betting.EPSILON_CHIPS) {
+ const ok = await Dialog.confirm({
+ title: `Throw away ${Money.format(unresolved)}?`,
+ message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
+ confirmLabel: "Throw it away",
+ danger: true,
+ });
+ if (!ok) return;
+ }
+
// startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
+ const discarded = unresolved > Betting.EPSILON_CHIPS
+ ? ` ${Money.format(unresolved)} taken off the table.`
+ : "";
- runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
+ runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => {
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
index f9d23fe..c4c47f9 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
@@ -272,11 +272,25 @@ const PotWizard = {
});
actions.appendChild(applyBtn);
- const closeBtn = document.createElement("button");
- closeBtn.className = "holdemBtn holdemBtnGhost";
- closeBtn.textContent = "Close";
- closeBtn.addEventListener("click", () => this.close());
- actions.appendChild(closeBtn);
+ // This button cancels — it does not pay anybody. Under a heading reading
+ // "Final Payouts" with an amount beside a name, "Close" reads like dismissing
+ // something already done, and a dealer who taps it walks away believing the
+ // pot has been pushed. It hasn't: the chips are still in the middle and the
+ // hand isn't logged. So it says what it does.
+ const cancelBtn = document.createElement("button");
+ cancelBtn.className = "holdemBtn holdemBtnGhost";
+ cancelBtn.textContent = "Cancel — don't pay yet";
+ cancelBtn.addEventListener("click", () => this.close());
+ actions.appendChild(cancelBtn);
+
+ if (this.totals.size > 0) {
+ const note = document.createElement("p");
+ note.className = "fieldHint potWizardPendingNote";
+ note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
+ wrap.appendChild(actions);
+ wrap.appendChild(note);
+ return wrap;
+ }
wrap.appendChild(actions);
return wrap;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 9eac9b8..43e2937 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -167,6 +167,38 @@ const HoldemState = {
return !player.inHand;
},
+ // What starting the next hand would take off the table for good: exactly the
+ // fields startNextHand clears.
+ //
+ // Deliberately not totalPot(). The table-wide base pot is a standing ante — a
+ // stand-in for "everyone puts the same in every hand", used by a dealer who
+ // isn't tracking stacks — so it re-applies to the next hand rather than being
+ // lost with this one. Counting it would mean warning about money that isn't
+ // going anywhere, on every single hand, for exactly the dealers who chose the
+ // simplest way to run the table.
+ unresolvedContributions(state) {
+ const total = state.seats.reduce((sum, player) => {
+ const override = player.amountOverride;
+ let banked = 0;
+
+ if (override !== null && override !== undefined && String(override).trim() !== "") {
+ const parsed = Number(override);
+ if (!Number.isNaN(parsed)) banked = Math.max(0, parsed);
+ }
+
+ if (banked === 0) {
+ banked = (state.chipLegend || []).reduce((chips, chip) => {
+ const count = Number(player.chipCounts[chip.id]) || 0;
+ return chips + count * Number(chip.value || 0);
+ }, 0);
+ }
+
+ return sum + banked + Math.max(0, Number(player.committed) || 0);
+ }, 0);
+
+ return Money.round(total);
+ },
+
totalPot(state) {
return state.seats.reduce(
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
diff --git a/tests/holdem/potSafety.test.js b/tests/holdem/potSafety.test.js
new file mode 100644
index 0000000..b687839
--- /dev/null
+++ b/tests/holdem/potSafety.test.js
@@ -0,0 +1,103 @@
+// Chips only ever leave this tool because a dealer meant them to. Starting the next
+// hand clears every seat's contribution, so an unresolved pot is simply destroyed —
+// the UI now asks first, and the number it quotes has to be exactly what goes.
+const { test, describe } = require("node:test");
+const assert = require("node:assert/strict");
+const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load");
+
+// The same measure the confirmation uses.
+const unresolvedPot = state => HoldemState.unresolvedContributions(state);
+
+describe("what starting the next hand destroys", () => {
+ test("the quoted amount is exactly what leaves the table", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
+ Betting.sweepToHandTotals(state);
+
+ const before = chipsOnTable(state);
+ const quoted = unresolvedPot(state);
+ assert.ok(quoted > 0, "there is a pot to warn about");
+
+ HoldemState.startNextHand(state);
+
+ assert.equal(
+ Money.toCents(chipsOnTable(state)),
+ Money.toCents(before - quoted),
+ "the dealer must be told the real number, not an approximation"
+ );
+ });
+
+ test("chips still in front of players on the current street are counted too", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
+ Betting.beginHand(state); // blinds are `committed`, not yet banked
+ Betting.raiseTo(state, 0, 25); // and so is a raise mid-street
+
+ const before = chipsOnTable(state);
+ const quoted = unresolvedPot(state);
+ HoldemState.startNextHand(state);
+
+ assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted));
+ assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`);
+ });
+
+ test("a hand that was paid out properly leaves nothing to warn about", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
+ Betting.sweepToHandTotals(state);
+
+ const pot = HoldemState.totalPot(state);
+ const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }];
+ HoldemState.recordHand(state, payouts);
+ Betting.creditWinnings(state, payouts);
+ HoldemState.startNextHand(state);
+
+ assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand");
+ });
+
+ test("settling then dealing on conserves every chip", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
+ const start = chipsOnTable(state);
+
+ for (let hand = 0; hand < 5; hand++) {
+ Betting.beginHand(state);
+ const live = Betting.liveSeats(state);
+ live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); });
+ Betting.sweepToHandTotals(state);
+
+ const pot = HoldemState.totalPot(state);
+ const winner = Betting.liveSeats(state)[0];
+ const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
+ HoldemState.recordHand(state, payouts);
+ Betting.creditWinnings(state, payouts);
+ HoldemState.startNextHand(state);
+
+ assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`);
+ }
+ });
+
+ test("an empty table has no phantom pot to warn about", () => {
+ const state = makeTable({ seatCount: 6, seated: [] });
+ assert.equal(unresolvedPot(state), 0);
+ });
+
+ test("a standing ante is never warned about, because it isn't lost", () => {
+ // The table default is a stand-in for "everyone puts the same in every hand",
+ // used by a dealer not tracking stacks at all. It re-applies to the next hand
+ // rather than going anywhere, so counting it would nag this dealer before
+ // every single hand — which is why the warning measures the fields
+ // startNextHand actually clears rather than the pot badge.
+ const state = makeTable({ seatCount: 9, seated: [0, 1] });
+ state.basePotAmount = 25;
+ state.seats.forEach(p => { p.amountOverride = null; });
+
+ // The base pot carries into the next hand, so nothing is being thrown away
+ // and the dealer must not be asked to confirm anything.
+ assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot");
+ assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning");
+
+ HoldemState.startNextHand(state);
+ assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too");
+ });
+});
From 60d192a9ecc768c20ec6fce7c071ce9715a6698b Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 07:33:16 -0600
Subject: [PATCH 2/9] Let the banker check the cash box against the chips on
the table
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ran out of things I could find by auditing the UI, so I went and read what people
who run home games actually complain about. The recurring one isn't a dealing
mistake, it's a banking one: "the last two guys go to cash out at the end of some
all-night game and there isn't enough cash to cover the chips they have."
Every chip on the table is a promise to pay cash for it later. Nothing in here
checked that the promise could be kept, and there's no way to notice by eye —
it's always a rebuy nobody wrote down, or chips taken without paying, and it
stays silent until the box is empty and two people are still holding chips.
The books now add up, or say why not
HoldemState.bankReconciliation compares the cash taken in against every chip that
could be handed back — stacks, chips in front of players, and the pot waiting to
be won. The sidebar shows both numbers and what they mean:
Balanced — the cash covers every chip on the table.
$160 more in chips than cash taken. Someone's rebuy probably wasn't
recorded — the box will be short at cash-out.
$150 more cash than chips in play. Someone has paid for chips they
haven't been given.
Money in the middle counts as chips in play, so an open pot isn't reported as a
shortfall — otherwise it would cry wolf during almost every hand.
Players who had bought in were missing from the settle-up
sessionStandings was built from the hand log, so anybody who hadn't yet been in a
recorded hand didn't appear at all. Somebody hands over $200, sits down, folds a
few, and the banker's own record doesn't mention them — then they turn up at
cash-out holding chips the list never accounted for. Anyone with money in the
game is in the list now, whether or not they've played a logged hand.
The settle-up number was already being computed and hidden
cashPosition — chips in front of them less the cash they handed over — was
calculated for every player and written to the CSV, but never shown on screen.
It's the number you read while counting out cash at the end of the night, so it's
a column now.
15 tests (127 total): a buy-in with no hands played still appears and reads as
level rather than a loss, empty chairs stay out, a player who cashed out shows
what they took, both directions of imbalance are caught, an open pot is not a
discrepancy, six played-and-settled hands never knock the books out, a proper
rebuy balances while a chip grab doesn't, and float dust isn't an alarm.
Validated with Playwright through real play — seated, blinds posted, pot open,
hand settled, rebuy — balanced at every step, plus the three seeded verdicts and
a fresh table where the panel stays hidden.
Sources: pokerlistings.com/blog/how-to-run-a-home-poker-game,
chipsoffury.com/learn/how-to-handle-poker-buy-ins-with-friends/
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 16 +-
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 44 ++++-
.../wwwroot/js/casino/holdem/render.js | 38 ++++
.../wwwroot/js/casino/holdem/state.js | 48 +++++
tests/holdem/bankAudit.test.js | 169 ++++++++++++++++++
5 files changed, 313 insertions(+), 2 deletions(-)
create mode 100644 tests/holdem/bankAudit.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index a44a751..c8d0e9d 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -110,10 +110,23 @@
In
Won
Net
+ Cash out
- Once you resolve a pot, everyone's running total for the night shows up here.
+ Everyone who has bought in shows up here, with what they'd be owed if the night ended now.
+
+
+
+ Cash taken
+
+
+
+ Chips in play
+
+
+
+
Hands
@@ -225,6 +238,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 8687072..d6e46f1 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1467,7 +1467,7 @@ button,
.standingsRow {
display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto auto;
+ grid-template-columns: minmax(0, 1fr) auto auto auto auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.3rem 0.1rem;
@@ -1481,6 +1481,48 @@ button,
min-width: 4.5rem;
}
+/* The settle-up column is the one a banker reads while counting out cash, so it
+ carries the weight the per-hand columns don't. */
+.standingsCashOut {
+ font-weight: 600;
+}
+
+/* ---------- Bank reconciliation ----------
+ Cash taken versus chips on the table. Every chip is a promise to pay cash for it
+ later, and if the chips outrun the box that promise can't be kept — which a dealer
+ otherwise discovers when the last two players cash out and there's nothing left. */
+.bankReconcile {
+ margin-top: 0.6rem;
+ padding: 0.55rem 0.6rem;
+ border: 1px solid #2f2f2f;
+ border-radius: 8px;
+ background: #151515;
+}
+
+.bankReconcileRow {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ font-size: 0.78rem;
+ color: #b8b8b8;
+ font-variant-numeric: tabular-nums;
+ padding: 0.12rem 0;
+}
+
+.bankReconcileVerdict {
+ margin: 0.4rem 0 0;
+ font-size: 0.74rem;
+ line-height: 1.35;
+}
+
+.bankReconcileVerdict.bankBalanced {
+ color: #7fc99a;
+}
+
+.bankReconcileVerdict.bankOff {
+ color: #f0b46a;
+}
+
.standingsHeader {
color: #8a8a8a;
font-size: 0.7rem;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index eebe5b1..7956fd1 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -701,8 +701,46 @@ const Render = {
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
+ // What they'd actually be handed if the night ended now: chips in front of
+ // them less the cash they gave the banker. This was computed and written to
+ // the CSV all along, but never shown to the person doing the paying out.
+ const cashEl = node.querySelector(".standingsCashOut");
+ cashEl.textContent = Money.formatSigned(row.cashPosition);
+ cashEl.classList.toggle("netUp", row.cashPosition > 0);
+ cashEl.classList.toggle("netDown", row.cashPosition < 0);
+
list.appendChild(node);
});
+
+ this.renderBankReconciliation();
+ },
+
+ // Does the cash box cover the chips on the table? Nothing asked this before, and
+ // it's the question that decides whether the last two players can be paid.
+ renderBankReconciliation() {
+ const host = document.getElementById("bankReconcile");
+ if (!host) return;
+
+ const bank = HoldemState.bankReconciliation(this.state);
+ // Nothing to reconcile before anybody has handed over money or taken a chip.
+ host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0;
+ if (host.hidden) return;
+
+ document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
+ document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
+
+ const verdict = document.getElementById("bankReconcileVerdict");
+ if (bank.balanced) {
+ verdict.textContent = "Balanced — the cash covers every chip on the table.";
+ } else if (bank.difference > 0) {
+ verdict.textContent =
+ `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
+ } else {
+ verdict.textContent =
+ `${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
+ }
+ verdict.classList.toggle("bankBalanced", bank.balanced);
+ verdict.classList.toggle("bankOff", !bank.balanced);
},
// ---- Hand counter + history ----
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 43e2937..1cd4163 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -384,6 +384,23 @@ const HoldemState = {
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
+ // Anyone who has money in the game belongs in the settle-up, whether or not
+ // they've been in a logged hand yet. Building the list from the hand log alone
+ // left a player who bought in and then sat through a few hands folding —
+ // or who just sat down — missing entirely, so the banker's own record didn't
+ // mention the cash they'd taken. They turn up at cash-out holding chips the
+ // list never accounted for.
+ state.seats.forEach((player, index) => {
+ if (totals.has(player.id)) return;
+ const hasMoneyInPlay =
+ (Number(player.buyIns) || 0) > 0 ||
+ (Number(player.stack) || 0) > 0 ||
+ (Number(player.amountOverride) || 0) > 0 ||
+ (Number(player.committed) || 0) > 0;
+ if (!hasMoneyInPlay) return;
+ totals.set(player.id, { id: player.id, name: this.displayName(player, index), in: 0, won: 0 });
+ });
+
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
@@ -408,6 +425,37 @@ const HoldemState = {
})
.sort((a, b) => b.net - a.net);
},
+
+ // The one check a banker actually needs, and the one nothing here did.
+ //
+ // Every chip on the table is a promise to pay cash for it later. If the chips in
+ // play add up to more than the cash that came in, that promise can't be kept —
+ // and the way you find out is the last two players cashing out and the box being
+ // empty. It's always someone taking chips without paying, a rebuy that never got
+ // recorded, or a miscount, and it's silent until it's too late to work out which.
+ //
+ // Chips in play counts everything a player could hand back: what's in front of
+ // them and what's in the middle waiting to be won.
+ bankReconciliation(state) {
+ const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
+ const chipsInPlay = state.seats.reduce(
+ (sum, p) =>
+ sum +
+ Math.max(0, Number(p.stack) || 0) +
+ Math.max(0, Number(p.committed) || 0) +
+ Math.max(0, Number(p.amountOverride) || 0),
+ 0
+ );
+
+ const difference = Money.round(chipsInPlay - cashIn);
+ return {
+ cashIn: Money.round(cashIn),
+ chipsInPlay: Money.round(chipsInPlay),
+ difference,
+ // Half a cent of float dust isn't a discrepancy worth alarming anyone over.
+ balanced: Math.abs(difference) <= Money.EPSILON,
+ };
+ },
};
if (typeof module !== "undefined" && module.exports) {
diff --git a/tests/holdem/bankAudit.test.js b/tests/holdem/bankAudit.test.js
new file mode 100644
index 0000000..eb2f2b2
--- /dev/null
+++ b/tests/holdem/bankAudit.test.js
@@ -0,0 +1,169 @@
+// The banker's job, which is a different job from the dealer's: every chip on the
+// table is a promise to pay cash for it later, and somebody has to be able to check
+// that the promise can be kept before the last two players try to cash out.
+const { test, describe } = require("node:test");
+const assert = require("node:assert/strict");
+const { Betting, HoldemState, Money, makeTable } = require("./load");
+
+const seat = (state, index, name, cash, chips) => {
+ const p = state.seats[index];
+ p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
+ return p;
+};
+
+describe("everyone with money in the game is accounted for", () => {
+ test("a player who has bought in but played no logged hand still appears", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
+ seat(state, 0, "Ann", 100, 100);
+ seat(state, 1, "Ben", 100, 100);
+ seat(state, 2, "Cal", 200, 200); // just sat down
+
+ state.seats[0].amountOverride = 10;
+ state.seats[1].amountOverride = 10;
+ HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
+ state.seats[0].amountOverride = null;
+ state.seats[1].amountOverride = null;
+
+ const names = HoldemState.sessionStandings(state).map(r => r.name);
+ assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
+ });
+
+ test("their row reads as no hands played, not as a loss", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
+ seat(state, 0, "Ann", 100, 100);
+ seat(state, 1, "Ben", 200, 200);
+
+ const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
+ assert.equal(cal.in, 0);
+ assert.equal(cal.won, 0);
+ assert.equal(cal.net, 0);
+ assert.equal(cal.boughtIn, 200);
+ assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
+ });
+
+ test("an empty chair is not dragged into the settle-up", () => {
+ const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
+ seat(state, 0, "Ann", 100, 100);
+ const rows = HoldemState.sessionStandings(state);
+ assert.equal(rows.length, 1);
+ });
+
+ test("somebody who has cashed out completely still shows what they took", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
+ seat(state, 0, "Ann", 100, 0); // handed every chip back
+ seat(state, 1, "Ben", 100, 200);
+ const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
+ assert.equal(ann.cashPosition, -100, "down the whole buy-in");
+ });
+});
+
+describe("does the cash box cover the chips on the table", () => {
+ test("a straight game balances", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
+ seat(state, 0, "Ann", 100, 60);
+ seat(state, 1, "Ben", 100, 140);
+ seat(state, 2, "Cal", 100, 100);
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.cashIn, 300);
+ assert.equal(bank.chipsInPlay, 300);
+ assert.equal(bank.difference, 0);
+ assert.equal(bank.balanced, true);
+ });
+
+ test("chips outrunning the cash box is caught before cash-out", () => {
+ // The 2am failure: the last players out can't be paid.
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
+ seat(state, 0, "Ann", 100, 100);
+ seat(state, 1, "Ben", 100, 100);
+ seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.cashIn, 300);
+ assert.equal(bank.chipsInPlay, 460);
+ assert.equal(bank.difference, 160);
+ assert.equal(bank.balanced, false);
+ });
+
+ test("cash taken for chips never handed over is caught too", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
+ seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
+ seat(state, 1, "Ben", 100, 100);
+
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.difference, -100);
+ assert.equal(bank.balanced, false);
+ });
+
+ test("chips in the middle still count as chips in play", () => {
+ // Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
+ // would report a shortfall every time a hand was open — which is nearly always.
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
+ state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
+
+ const before = HoldemState.bankReconciliation(state);
+ assert.equal(before.balanced, true);
+
+ Betting.beginHand(state); // blinds go in
+ Betting.raiseTo(state, 0, 25); // and a raise
+ Betting.sweepToHandTotals(state); // banked into the pot
+
+ const during = HoldemState.bankReconciliation(state);
+ assert.equal(during.balanced, true, "an open pot is not a discrepancy");
+ assert.equal(during.chipsInPlay, before.chipsInPlay);
+ });
+
+ test("a whole session of play never knocks the books out", () => {
+ const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
+ state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
+
+ for (let hand = 0; hand < 6; hand++) {
+ Betting.beginHand(state);
+ Betting.liveSeats(state).forEach(s => {
+ if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index);
+ });
+ Betting.sweepToHandTotals(state);
+
+ const pot = HoldemState.totalPot(state);
+ const winner = Betting.liveSeats(state)[0];
+ const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
+ HoldemState.recordHand(state, payouts);
+ Betting.creditWinnings(state, payouts);
+ HoldemState.startNextHand(state);
+
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
+ }
+ });
+
+ test("a rebuy keeps the books straight, a chip grab doesn't", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
+ seat(state, 0, "Ann", 100, 100);
+ seat(state, 1, "Ben", 100, 100);
+
+ // A proper rebuy: cash to the banker, chips to the player.
+ state.seats[0].buyIns += 100;
+ state.seats[0].stack += 100;
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+
+ // Chips taken without paying.
+ state.seats[1].stack += 50;
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.balanced, false);
+ assert.equal(bank.difference, 50);
+ });
+
+ test("nothing to reconcile before anybody has bought in", () => {
+ const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
+ const bank = HoldemState.bankReconciliation(state);
+ assert.equal(bank.cashIn, 0);
+ assert.equal(bank.chipsInPlay, 0);
+ assert.equal(bank.balanced, true);
+ });
+
+ test("float dust is not a discrepancy", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
+ seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
+ seat(state, 1, "Ben", 10, 10);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+});
From aeb02d023d1cd6f0fd96d2e6a95209418d5c4f55 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 07:52:08 -0600
Subject: [PATCH 3/9] A short all-in doesn't reopen the betting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Went looking at dealer procedure again and ended up checking the rule home games
argue about longest. The tool had it wrong.
Blinds 1/2. Seat 4 raises to 10 — a full raise of 8. Seat 1 calls. Seat 2 is
all-in for 13: three more, where a full raise would need eight. Under TDA rule 44
that does not reopen the betting to anyone who has already acted; they owe the
extra three or they fold. Seat 1 and seat 4 were both being handed another go.
Two things were wrong in raiseTo. Any increase at all reset hasActed for every
live player, so a short shove gave the whole table another chance to raise. And
lastRaiseSize was overwritten with the short amount, so the next legal raise was
computed off 3 instead of 8 and came out too small — a second, quieter error that
only shows up when somebody then tries to raise.
Both now key off whether the increase was actually a full raise. A player who had
not yet acted keeps full rights, which falls out naturally because their hasActed
is already false — that distinction is the part of the rule people get wrong in
the other direction.
Everyone still owes the difference: isStreetComplete already asks anyone with
chips left to call, so the short all-in is answered without being re-raiseable.
The rulings sheet now covers what the tool does for you
It had three entries. A dealer challenged on a payout couldn't look up why the
tool did what it did, even for the rules it enforces automatically. Added the
short all-in rule above, uncalled chips coming back rather than being announced
as a side pot, and the odd chip in a chopped pot going to the first player left
of the button — all three are things this tool already decides silently, and all
three are worth being able to point at when somebody objects.
10 tests (137 total): already-acted players aren't reopened but still owe the
difference, a player yet to act keeps full rights, the minimum raise isn't
shrunk, calling it ends the street, no chips are created, and — the other half of
the rule — a full-sized all-in, an ordinary raise, a raise exactly matching the
last one, and a full-sized opening bet all still reopen everyone.
Validated through the real action bar: after the short shove, lastRaiseSize stays
8 and the player who had already called stays settled, while the two who hadn't
acted keep their turn. The longer rulings sheet still fits a phone, scrolls, traps
focus and closes on Escape.
Sources: TDA rules via thelodgepokerclub.com/tda-rules/,
pokernews.com/strategy/deal-with-it-tips-for-mistake-free-dealing-in-home-games-30231.htm
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 35 +++++
.../wwwroot/js/casino/holdem/betting.js | 31 +++-
tests/holdem/reopeningBetting.test.js | 143 ++++++++++++++++++
3 files changed, 203 insertions(+), 6 deletions(-)
create mode 100644 tests/holdem/reopeningBetting.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index c8d0e9d..1af4097 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -313,6 +313,41 @@
+
+ A short all-in doesn't reopen the betting
+
+ Somebody shoving in less than a full raise — say $3 more when the last
+ raise was $8 — does not give the players behind them another go. Anyone
+ who has already acted owes the difference or folds; they don't get to
+ raise again because a short stack happened to land between them.
+
+
+ A player who hadn't acted yet keeps full rights and may still raise, and
+ the minimum raise stays measured off the last full raise, not
+ off the short one. The action bar follows this for you.
+
+
+
+
+ Chips nobody called come back
+
+ Bet more than anyone was able to call and the excess is returned to you
+ before the showdown — it was never contested, so it isn't part of the
+ pot. The pot breakdown shows it as "uncalled" rather than pretending it's
+ a side pot you could win.
+
+
+
+
+ The odd chip in a split pot
+
+ When a chopped pot won't divide evenly, the odd chip goes to the first
+ player left of the button, not to whoever the dealer happened to tick
+ first. Pot payouts here order the winners that way, so the leftover lands
+ on the right seat every time.
+
+
+
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 803d739..29f293f 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -153,14 +153,33 @@ const Betting = {
const delta = this.round(target - this.committed(player));
const moved = this.putIn(state, index, delta);
const newBet = this.committed(player);
+ const previousBet = state.currentBet || 0;
+
+ if (newBet > previousBet) {
+ const increase = this.round(newBet - previousBet);
+ // What a full raise would have had to be: at least as much again as the
+ // last one, or the big blind if there hasn't been a raise yet.
+ const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
+ const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS;
- if (newBet > (state.currentBet || 0)) {
- state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
state.currentBet = newBet;
- // A raise reopens the action: everyone else owes a response again.
- state.seats.forEach((p, i) => {
- if (i !== index && p.inHand && !p.folded) p.hasActed = false;
- });
+
+ if (isFullRaise) {
+ state.lastRaiseSize = increase;
+ // A real raise reopens the action: everyone else owes a response again.
+ state.seats.forEach((p, i) => {
+ if (i !== index && p.inHand && !p.folded) p.hasActed = false;
+ });
+ }
+ // Otherwise this is a short all-in — somebody with less than a full raise
+ // left shoving it in. The most misread rule in poker: it does *not* reopen
+ // the betting to anyone who has already acted. They owe the difference or
+ // they fold; they don't get to raise again because a short stack happened
+ // to be between them. Players yet to act keep their full rights, which
+ // they do here because their hasActed is already false.
+ //
+ // lastRaiseSize deliberately isn't lowered either, or the next legal raise
+ // would be computed off the short amount and come out too small.
}
player.hasActed = true;
diff --git a/tests/holdem/reopeningBetting.test.js b/tests/holdem/reopeningBetting.test.js
new file mode 100644
index 0000000..7ee548d
--- /dev/null
+++ b/tests/holdem/reopeningBetting.test.js
@@ -0,0 +1,143 @@
+// When does a bet reopen the action? The most misread rule in poker, and the one a
+// home game argues about longest: a player shoving in less than a full raise does not
+// give everybody behind them another go.
+//
+// TDA Rule 44 — an all-in wager of less than a full raise does not reopen the betting
+// to a player who has already acted and is not facing a full raise.
+const { test, describe } = require("node:test");
+const assert = require("node:assert/strict");
+const { Betting, makeTable, chipsOnTable } = require("./load");
+
+const actedFlags = state => state.seats
+ .map((p, i) => ({ seat: i, acted: p.hasActed, live: p.inHand && !p.folded }))
+ .filter(s => s.live);
+
+describe("a short all-in does not reopen the betting", () => {
+ // Blinds 1/2. Seat 4 raises to 10 (a full raise of 8). Seat 1 calls.
+ // Seat 2 is all-in for 13 — three more, where a full raise needs eight.
+ const shortAllInTable = () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ state.seats[1].stack = 13;
+ Betting.beginHand(state);
+ Betting.raiseTo(state, 3, 10);
+ Betting.call(state, 0);
+ return state;
+ };
+
+ test("players who already acted are not asked to act again", () => {
+ const state = shortAllInTable();
+ assert.equal(state.seats[0].hasActed, true, "seat 1 has called");
+ assert.equal(state.seats[3].hasActed, true, "seat 4 has raised");
+
+ Betting.allIn(state, 1);
+
+ assert.equal(state.seats[0].hasActed, true, "seat 1 must not be reopened");
+ assert.equal(state.seats[3].hasActed, true, "seat 4 must not be reopened");
+ });
+
+ test("but they do still owe the difference", () => {
+ const state = shortAllInTable();
+ Betting.allIn(state, 1);
+
+ assert.equal(Betting.toCall(state, state.seats[0]), 3, "seat 1 owes the extra 3");
+ assert.equal(Betting.isStreetComplete(state), false, "so the street isn't over");
+ });
+
+ test("a player who had not acted yet keeps full rights", () => {
+ const state = shortAllInTable();
+ assert.equal(state.seats[2].hasActed, false, "seat 3 hasn't acted");
+ Betting.allIn(state, 1);
+ assert.equal(state.seats[2].hasActed, false, "and still hasn't — they may still raise");
+ });
+
+ test("the minimum raise is not shrunk by the short all-in", () => {
+ const state = shortAllInTable();
+ assert.equal(state.lastRaiseSize, 8);
+
+ Betting.allIn(state, 1);
+
+ assert.equal(state.lastRaiseSize, 8, "still the last *full* raise");
+ assert.equal(Betting.minRaiseTo(state), 21, "13 to call plus a full raise of 8");
+ });
+
+ test("calling the short all-in ends the street", () => {
+ const state = shortAllInTable();
+ Betting.allIn(state, 1);
+ Betting.call(state, 2); // seat 3, who had full rights, just calls
+ Betting.call(state, 3);
+ Betting.call(state, 0);
+ assert.equal(Betting.isStreetComplete(state), true);
+ });
+
+ test("no chips are created or lost by any of it", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ state.seats[1].stack = 13;
+ const start = chipsOnTable(state);
+ Betting.beginHand(state);
+ Betting.raiseTo(state, 3, 10);
+ Betting.call(state, 0);
+ Betting.allIn(state, 1);
+ Betting.call(state, 2);
+ Betting.call(state, 3);
+ Betting.call(state, 0);
+ assert.equal(chipsOnTable(state), start);
+ });
+});
+
+describe("a full raise still reopens the betting", () => {
+ test("an all-in that is a full raise or more reopens everyone", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ state.seats[1].stack = 40;
+ Betting.beginHand(state);
+ Betting.raiseTo(state, 3, 10);
+ Betting.call(state, 0);
+
+ Betting.allIn(state, 1); // to 40: an increase of 30, well over the 8 needed
+
+ assert.equal(state.seats[0].hasActed, false, "seat 1 gets another go");
+ assert.equal(state.seats[3].hasActed, false, "and so does seat 4");
+ assert.equal(state.lastRaiseSize, 30);
+ assert.equal(Betting.minRaiseTo(state), 70);
+ });
+
+ test("an ordinary raise reopens everyone", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ Betting.call(state, 3);
+ Betting.call(state, 0);
+ assert.equal(state.seats[3].hasActed, true);
+
+ Betting.raiseTo(state, 1, 10);
+
+ assert.equal(state.seats[3].hasActed, false);
+ assert.equal(state.seats[0].hasActed, false);
+ });
+
+ test("a raise exactly the size of the last one counts as full", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ Betting.raiseTo(state, 3, 10); // raise of 8
+ Betting.call(state, 0);
+
+ Betting.raiseTo(state, 1, 18); // exactly 8 more — a legal minimum raise
+
+ assert.equal(state.seats[0].hasActed, false, "a minimum raise still reopens");
+ assert.equal(state.lastRaiseSize, 8);
+ });
+
+ test("the first raise of a street is measured against the big blind", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
+ Betting.beginHand(state);
+ Betting.call(state, 3);
+ Betting.call(state, 0);
+ Betting.call(state, 1);
+ Betting.check(state, 2);
+ Betting.closeStreet(state); // to the flop, no bet yet
+
+ const first = Betting.nextToAct(state, state.dealerSeatIndex);
+ Betting.raiseTo(state, first, 2); // a bet of exactly one big blind
+
+ const others = state.seats.filter((p, i) => p.inHand && !p.folded && i !== first);
+ assert.ok(others.every(p => p.hasActed === false), "a full-sized opening bet reopens everyone");
+ });
+});
From 0d6e7e0b5ae912d6c1cbf51965ac0b7b3b64ba53 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 08:07:51 -0600
Subject: [PATCH 4/9] Give the standings table back its player names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Went looking for more rules bugs and didn't find any — the blind rotation is fair
over an orbit, nobody is double-charged or skipped when players come and go, and
the big blind's option is handled correctly including heads-up and when the blind
puts them all-in. Those probes are written up below.
What I did find was a regression I shipped two rounds ago.
The name column was zero pixels wide
Adding the "Cash out" column in the bank-audit round made five columns. Four of
them are numbers with a 4.5rem minimum, which with gaps needs 320px — and the
sidebar is about 290px. The name column is the only flexible one, so it absorbed
the shortfall and collapsed to nothing. Player names were not truncated, they
were entirely invisible, and the row quietly overflowed.
Measured at 360, 390, 1024, 1280 and 1600 alike, because the sidebar is narrow at
all of them — this was not a phone problem, it was everywhere except the tablet
layout where the sidebar goes full width. Names were already down to 50px with
four columns, so this had been getting worse before I finished it off.
The table earns its columns now instead of assuming them
Narrow: player, net, cash out — the two numbers a banker settles up from. Wide
enough: the per-hand In and Won as well. A container query rather than a viewport
one, because the sidebar is 290px at both 360px and 1600px viewports and the
thing that matters is the space the table actually has. Names get a real minimum
and ellipsis rather than being crushed. Everything stays in the CSV either way.
360x640 name 0px -> 152px Player | Net | Cash out
390x844 name 0px -> 182px Player | Net | Cash out
768x1024 name -> 389px Player | In | Won | Net | Cash out
1280x900 name 0px -> 151px Player | Net | Cash out
Rules probes that came back clean, so nothing changed:
- blind rotation over 12 hands with 4 players: exactly 3 big blinds each
- a player leaving and rejoining mid-session never causes a double or a skip
- the big blind gets their option when everyone limps, can raise with it,
and checking it ends the street
- a big blind left all-in by the blind itself gets no phantom option
- heads-up, the button acts first pre-flop and the big blind still has the option
- post-flop the minimum bet falls back to the big blind
Re-checked the guarantees from earlier rounds with the sidebar open: 0 of 50
controls on touch-action auto, 0 tap-highlighted, 0 occluded, stacks still inside
their pucks, no sideways scroll, bank panel still fits and reads correctly.
137 tests still passing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 18 ++++----
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 46 ++++++++++++++++++-
2 files changed, 54 insertions(+), 10 deletions(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 1af4097..6035869 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -104,15 +104,17 @@
Session
Standings
-
-
-
Player
-
In
-
Won
-
Net
-
Cash out
+
+
+
+ Player
+ In
+ Won
+ Net
+ Cash out
+
+
-
Everyone who has bought in shows up here, with what they'd be owed if the night ended now.
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index d6e46f1..5d866b1 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1465,9 +1465,22 @@ button,
overflow-y: auto;
}
+/* The sidebar is about 290px wide, and four numeric columns at a 4.5rem minimum
+ plus gaps come to 320px — so the name column, being the only flexible one, was
+ collapsing to *zero* and player names disappeared entirely. Names were already
+ down to 50px with four columns; adding the cash-out column finished them off.
+ Measured at 360, 390 and 1280 alike, since the sidebar is narrow at all of them.
+
+ So the table earns its columns instead of assuming them. Narrow: the player and
+ the two numbers a banker settles up from. Wide enough: the per-hand detail as
+ well. Everything is in the CSV export either way. */
+.standingsTableWrap {
+ container-type: inline-size;
+}
+
.standingsRow {
display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto auto auto;
+ grid-template-columns: minmax(4rem, 1fr) auto auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.3rem 0.1rem;
@@ -1478,7 +1491,36 @@ button,
.standingsRow > span:not(:first-child) {
text-align: right;
font-variant-numeric: tabular-nums;
- min-width: 4.5rem;
+ min-width: 3.6rem;
+}
+
+.standingsRow > .standingsName {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* The per-hand columns are detail: how the night's swings happened. They're the
+ first thing to go when there isn't room, because Net and Cash out are what the
+ banker is actually reading. */
+.standingsIn,
+.standingsWon,
+.standingsInHead,
+.standingsWonHead {
+ display: none;
+}
+
+@container (min-width: 30rem) {
+ .standingsRow {
+ grid-template-columns: minmax(5rem, 1fr) auto auto auto auto;
+ }
+
+ .standingsIn,
+ .standingsWon,
+ .standingsInHead,
+ .standingsWonHead {
+ display: block;
+ }
}
/* The settle-up column is the one a banker reads while counting out cash, so it
From 2f6d63400a8dec18d6b821acb0ce9069212eeb64 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 08:31:35 -0600
Subject: [PATCH 5/9] Stop re-typing the layout audit into a console every
round
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Last round I found a regression I'd shipped two rounds earlier: the standings
table's player-name column was zero pixels wide, at every viewport, and nobody
noticed because the audit that would have caught it only ever existed in a
previous session's scrollback. These checks have been re-written by hand at least
six times, and each time they covered only whatever had just changed.
So they're a file now. uiAudit.js runs in the page — no build step, no
package.json, no dependency — and checks touch-action and tap highlights, that
fields stay selectable and are big enough not to trigger iOS zoom, that nothing
important is covered or stranded off-screen, that no grid column has collapsed to
nothing, and that no text is painted outside its box. runUiAudit.js drives it over
the table, all five overlays and Quick Split at thirteen viewports. Deliberately
outside CI: the unit suite is dependency-free and runs in a second, this one needs
a browser and a running site.
Three real faults it found on its first run
Fields were 13.3px on every iPad. The 16px minimum that stops iOS zooming the page
on focus went in inside a max-width:700px block, and every iPad is wider than
that — so a dealer on a tablet, which is the device this is most likely to be
propped up on, got the table zoomed every time they touched the blind amount. The
reason for that rule was never "narrow screen", it was "iOS". Font size applies at
all widths now, and the 44px touch targets key off hover:none, which is what
actually distinguishes a finger from a mouse.
The seat menu couldn't be scrolled. It had no height limit and no overflow, so on
a phone held sideways its first two actions — Fold and All-in — sat below the
bottom of the screen with no way to reach them. A dealer in landscape could
long-press a seat and then not fold it.
The pot wizard sat under the action bar. Every other overlay is z-index 90-98; the
wizard alone was at 50, below both the action bar at 55 and the toast at 80. In
landscape one of its inputs was completely covered by the Fold button, and a toast
could sit over "Confirm Winner(s)". It was the odd one out rather than a decision.
Three faults in the audit itself, found by checking before acting
Worth recording, because each would have had me "fix" something that wasn't broken:
- an ellipsised name was reported as painting outside its seat. A Range measures
laid-out text, which runs past the element while overflow:hidden quietly clips
it. Only overflow that is actually visible can paint over a neighbour.
- every link below the fold was reported as off-screen. Something under the fold on
a page that scrolls is reachable; the check now asks whether scrolling can bring
it into view, which is the thing that actually matters.
- a button in the newly-scrollable seat menu came back "covered by null".
elementFromPoint returns null for a point outside a scrollable ancestor's clip —
scrolled out of view, not covered by anything.
A toast partly over a control is a warning rather than a failure now: it dismisses
itself and carries a close button, and on a desktop it necessarily lands over a
table that fills the content column. Fully covered by a toast is still a failure,
because that was a real bug once — a seat sat exactly under the Undo button.
Final sweep: 13 viewports, 65 overlay checks, 13 full-page audits, 4 Quick Split
audits, zero failures, one warning. 137 unit tests still passing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 51 +++-
tests/holdem/README.md | 26 ++
tests/holdem/runUiAudit.js | 134 ++++++++++
tests/holdem/uiAudit.js | 278 ++++++++++++++++++++
4 files changed, 487 insertions(+), 2 deletions(-)
create mode 100644 tests/holdem/runUiAudit.js
create mode 100644 tests/holdem/uiAudit.js
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 5d866b1..9569337 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1040,6 +1040,13 @@ button,
border-radius: 16px 16px 0 0;
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
+ /* This sheet had no height limit and didn't scroll, so on a phone held sideways
+ its first two actions — Fold and All-in, the two that matter — sat below the
+ bottom of the screen with no way to reach them. A dealer in landscape could
+ long-press a seat and then not fold it. */
+ max-height: 85vh;
+ overflow-y: auto;
+ overscroll-behavior: contain;
}
.seatMenuHeader {
@@ -2207,7 +2214,12 @@ body.hasActionBar #toastHost {
display: flex;
align-items: center;
justify-content: center;
- z-index: 50;
+ /* Every other sheet in here sits at 90-98; this one was at 50, underneath both
+ the action bar (55) and the toast (80). So the bar drew on top of the dialog
+ that pays people — in landscape one of its inputs was completely covered by
+ the Fold button — and a toast could sit over "Confirm Winner(s)". It's the odd
+ one out rather than a deliberate choice, and it joins the others. */
+ z-index: 95;
padding: 1.5rem;
}
@@ -2221,8 +2233,13 @@ body.hasActionBar #toastHost {
border-radius: 10px;
width: 100%;
max-width: 480px;
- max-height: 85vh;
+ /* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over
+ the bottom of it — so the last of the wizard sat underneath the bar and its
+ controls couldn't be tapped. Leaving the bar's own height out of the budget
+ keeps the whole dialog above it. */
+ max-height: calc(85vh - var(--actionBarHeight, 0px));
overflow-y: auto;
+ overscroll-behavior: contain;
padding: 1.2rem 1.4rem 1.6rem;
}
@@ -2816,3 +2833,33 @@ textarea:focus-visible {
[role="alertdialog"]:focus {
outline: none;
}
+
+/* ---------- Fields on a tablet ----------
+ The 16px minimum and the 44px touch targets were both written into the phone
+ media query, so every iPad size missed them — measured at 768, 834 and 1024 wide,
+ where all ten setup fields sat at 13.3px. iOS zooms the page whenever a field
+ under 16px takes focus, so a dealer on an iPad was getting the table zoomed every
+ time they touched the blind amount, then pinching back out. The reason for the
+ rule was never "narrow screen", it was "iOS", and an iPad is iOS.
+
+ Font size applies everywhere now — 13.3px was small for a settings field on a
+ desktop anyway. The touch sizing keys off `hover: none`, which is what actually
+ distinguishes a finger from a mouse; a tablet gets thumb-sized controls and a
+ desktop keeps its compact sidebar. */
+.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
+.panelBody input:not([type="checkbox"]):not([type="color"]),
+.subPanelBody input:not([type="checkbox"]):not([type="color"]),
+.panelBody select,
+.panelBody textarea {
+ font-size: 16px;
+}
+
+@media (hover: none) {
+ .seatEditorForm input:not([type="checkbox"]):not([type="color"]),
+ .panelBody input:not([type="checkbox"]):not([type="color"]),
+ .subPanelBody input:not([type="checkbox"]):not([type="color"]),
+ .panelBody select {
+ min-height: 44px;
+ padding: 0.4rem 0.6rem;
+ }
+}
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index 34582a9..7c865b9 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -22,6 +22,32 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
| `invariants.test.js` | 1000 randomised hands per property (see below) |
+## The layout audit
+
+The unit tests above cover the money. `uiAudit.js` covers the thing you can only see
+in a browser: whether a dealer can actually reach every control.
+
+```bash
+node tests/holdem/runUiAudit.js # needs Playwright and the site running
+```
+
+It checks touch-action and tap highlights, that fields stay selectable and are large
+enough not to trigger iOS zoom, that nothing important is covered or off-screen with
+no way to scroll to it, that no grid column has collapsed to nothing, and that no
+text is painted outside its box. It runs over the table, all five overlays and Quick
+Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
+phones on their side.
+
+Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
+this one needs a browser and a running site. Run it after any change that adds or
+resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
+call `LayoutAudit.runAll()`.
+
+It exists because these checks were being re-typed into a console every time
+something changed, and each time they only covered whatever had just been touched.
+That is how the standings table shipped with a player-name column zero pixels wide
+and kept it for two rounds.
+
## Why the randomised suite exists
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js
new file mode 100644
index 0000000..33ba18f
--- /dev/null
+++ b/tests/holdem/runUiAudit.js
@@ -0,0 +1,134 @@
+// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about.
+//
+// node tests/holdem/runUiAudit.js [baseUrl]
+//
+// Needs Playwright available (`npx playwright install chromium` once). It is
+// deliberately NOT wired into the unit-test suite or CI, because that suite is
+// dependency-free and runs in a second — this one needs a browser and a running
+// site. Run it after any change that adds or resizes a control.
+//
+// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools,
+// and call LayoutAudit.runAll(). Same checks, one viewport.
+const path = require("path");
+
+const BASE = process.argv[2] || "http://localhost:5299";
+const AUDIT = path.join(__dirname, "uiAudit.js");
+
+// Portrait phones, tablets, desktops, then the same phones on their side — landscape
+// is where this layout has broken most often, because the table is sized from width
+// and the action bar is fixed to a very short viewport.
+const VIEWPORTS = [
+ [360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"],
+ [412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"],
+ [768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"],
+ [1280, 900, "desktop"], [1600, 1000, "wide desktop"],
+ [640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"],
+];
+
+const OVERLAYS = [
+ ["rulings", () => document.getElementById("openRulingsBtn").click()],
+ ["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
+ ["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
+ ["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
+ ["pot wizard", () => document.getElementById("resolvePotBtn").click()],
+];
+
+// A table part-way through a real night: long names, uneven stacks, a hand log.
+function seedTable() {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, 9);
+ [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
+ .forEach(([name, cash], i) => {
+ state.seats[i].name = name;
+ state.seats[i].inHand = true;
+ state.seats[i].buyIns = cash;
+ state.seats[i].stack = cash;
+ });
+ state.smallBlind = 5;
+ state.bigBlind = 10;
+ for (let hand = 1; hand <= 4; hand++) {
+ state.handHistory.push({
+ handNumber: hand, timestamp: Date.now(), pot: 120,
+ payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
+ ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
+ });
+ }
+ state.handNumber = 5;
+ HoldemState.save(state);
+}
+
+async function main() {
+ let chromium;
+ try {
+ ({ chromium } = require("playwright"));
+ } catch {
+ console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
+ console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll().");
+ process.exit(2);
+ }
+
+ const browser = await chromium.launch();
+ const page = await browser.newPage();
+ const failures = [];
+ const warnings = [];
+ let checks = 0;
+
+ for (const [width, height, label] of VIEWPORTS) {
+ await page.setViewportSize({ width, height });
+
+ await page.goto(`${BASE}/Casino/TexasHoldem`);
+ await page.evaluate(seedTable);
+ await page.goto(`${BASE}/Casino/TexasHoldem`);
+ await page.click("#nextHandBtn");
+ await page.waitForTimeout(300);
+ await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
+ await page.addScriptTag({ path: AUDIT });
+
+ const table = await page.evaluate(() => LayoutAudit.runAll());
+ checks++;
+ if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
+ table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
+
+ for (const [name, open] of OVERLAYS) {
+ await page.evaluate(open);
+ await page.waitForTimeout(180);
+ checks++;
+ const result = await page.evaluate(() => {
+ const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
+ if (!overlay) return { offenders: [] };
+ return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
+ });
+ await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
+ if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
+ }
+ }
+
+ for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
+ await page.setViewportSize({ width, height });
+ await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
+ await page.evaluate(() => {
+ const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
+ if (button) button.click();
+ });
+ await page.addScriptTag({ path: AUDIT });
+ checks++;
+ const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
+ if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
+ }
+
+ await browser.close();
+
+ console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
+ warnings.forEach(w => console.log(` note: ${w}`));
+ if (failures.length === 0) {
+ console.log("clean");
+ return;
+ }
+ failures.forEach(f => {
+ console.log(`\nFAIL ${f.where}`);
+ f.offenders.forEach(o => console.log(` ${o}`));
+ });
+ process.exit(1);
+}
+
+main();
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
new file mode 100644
index 0000000..7768ddd
--- /dev/null
+++ b/tests/holdem/uiAudit.js
@@ -0,0 +1,278 @@
+// Layout and touch audits for the dealer, written once so they stop being written
+// again every round.
+//
+// These checks have been re-typed into a browser console at least six times over
+// the course of building this thing, and each time they only covered whatever had
+// just been changed. That is exactly how the standings table shipped with a
+// zero-pixel player-name column and survived two rounds: the audit that would have
+// caught it existed only in a previous session's scrollback.
+//
+// This file is deliberately dependency-free and browser-side. It is injected into
+// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
+// package.json, and no browser automation of its own — anyone can also paste it
+// into devtools and call LayoutAudit.runAll().
+const LayoutAudit = {
+ // Everything a dealer has to be able to hit.
+ CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
+
+ describe(el) {
+ if (!el) return "(nothing)";
+ const id = el.id ? `#${el.id}` : "";
+ const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
+ return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
+ },
+
+ visibleControls() {
+ return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
+ const rect = el.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return false;
+ const style = getComputedStyle(el);
+ return style.visibility !== "hidden" && style.display !== "none";
+ });
+ },
+
+ // ---- Touch (see the mobile round) ----
+
+ // Without touch-action: manipulation a mobile browser holds every tap for ~300ms
+ // deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
+ // seat as its fold shortcut, so that wait is on the critical path.
+ touchAction() {
+ const offenders = this.visibleControls()
+ .filter(el => getComputedStyle(el).touchAction === "auto")
+ .map(el => this.describe(el));
+ return { name: "touch-action", offenders, checked: this.visibleControls().length };
+ },
+
+ tapHighlight() {
+ const offenders = this.visibleControls()
+ .filter(el => {
+ const colour = getComputedStyle(el).webkitTapHighlightColor;
+ return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
+ })
+ .map(el => this.describe(el));
+ return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
+ },
+
+ // Text selection must stay ON for anything a dealer types into.
+ textSelectable() {
+ const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
+ .filter(el => el.offsetParent !== null)
+ .filter(el => {
+ const style = getComputedStyle(el);
+ return (style.webkitUserSelect || style.userSelect) === "none";
+ })
+ .map(el => this.describe(el));
+ return { name: "fields stay selectable", offenders };
+ },
+
+ // ---- Reachability (see the occlusion round) ----
+
+ // A control covered by something else is unreachable however good it looks.
+ // Sampling several points matters: a button you can only hit in one corner is
+ // covered for practical purposes.
+ // Can this element be brought into view by scrolling? Something below the fold on
+ // a page that scrolls is perfectly reachable; something below the fold in a panel
+ // that *doesn't* scroll is lost. The first version of this check didn't make the
+ // distinction and reported every link under the fold as a fault.
+ reachableByScrolling(el) {
+ const rect = el.getBoundingClientRect();
+ if (getComputedStyle(el).position === "fixed") return false;
+
+ for (let node = el.parentElement; node; node = node.parentElement) {
+ const style = getComputedStyle(node);
+ const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
+ if (!scrolls) continue;
+ const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
+ const room = node === document.body
+ ? document.documentElement.scrollHeight - innerHeight - scrollY
+ : node.scrollHeight - node.clientHeight - node.scrollTop;
+ if (rect.top >= box.bottom && room > 0) return true;
+ if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
+ }
+ return false;
+ },
+
+ occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
+ const offenders = [];
+ const warnings = [];
+ [...document.querySelectorAll(selector)].forEach(el => {
+ const rect = el.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+
+ const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
+ rect.right <= 0 || rect.left >= innerWidth;
+ if (outsideViewport) {
+ if (!this.reachableByScrolling(el)) {
+ offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
+ }
+ return;
+ }
+
+ const points = [
+ [rect.left + rect.width / 2, rect.top + rect.height / 2],
+ [rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
+ [rect.left + rect.width * 0.75, rect.top + rect.height * 0.3],
+ [rect.left + rect.width * 0.25, rect.top + rect.height * 0.7],
+ [rect.left + rect.width * 0.75, rect.top + rect.height * 0.7],
+ ].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight);
+ if (points.length === 0) return;
+
+ let blocked = 0;
+ let testable = 0;
+ let blockedBy = null;
+ points.forEach(([x, y]) => {
+ const top = document.elementFromPoint(x, y);
+ // A null result means the point isn't hit-testable — typically it sits
+ // inside the element but outside a scrollable ancestor's clip, i.e. the
+ // control is scrolled out of view rather than covered by anything.
+ // Counting that as "covered by null" was reporting a perfectly
+ // reachable button in a sheet that scrolls.
+ if (!top) return;
+ testable++;
+ if (el.contains(top) || top === el || top.contains(el)) return;
+ // Seats ring a round table and overlap each other at the rim by
+ // design; that is crowding, not an overlay hiding a control.
+ if (top.closest && top.closest(".seat")) return;
+ blocked++;
+ if (!blockedBy) blockedBy = this.describe(top);
+ });
+
+ if (testable === 0) {
+ if (!this.reachableByScrolling(el)) {
+ offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`);
+ }
+ return;
+ }
+ if (blocked === 0) return;
+
+ const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`;
+
+ // A toast is transient by design: it dismisses itself and carries a close
+ // button, and on a desktop it necessarily lands over a table that fills the
+ // content column. Partial overlap from one is a fact of the layout, not a
+ // fault. Being *fully* covered by a toast is still a failure though — that
+ // was a real bug once, where a seat sat exactly under the Undo button and a
+ // tap meant for a player took back the last action instead.
+ const transient = blockedBy && /toast/i.test(blockedBy);
+ if (transient && blocked < testable) warnings.push(note);
+ else offenders.push(note);
+ });
+ return { name: "nothing important is covered", offenders, warnings };
+ },
+
+ // ---- Layout (the check that would have caught the standings regression) ----
+
+ // A grid column squeezed to nothing doesn't truncate its text, it erases it.
+ // This is the one that was missing: the player-name column sat at 0px through
+ // two rounds because nothing ever asked whether a column had survived.
+ collapsedColumns() {
+ const offenders = [];
+ [...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
+ if (getComputedStyle(row).display !== "grid") return;
+ [...row.children].forEach(cell => {
+ if (getComputedStyle(cell).display === "none") return;
+ const hasText = (cell.textContent || "").trim().length > 0;
+ if (hasText && cell.getBoundingClientRect().width < 4) {
+ offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`);
+ }
+ });
+ });
+ return { name: "no column collapsed to nothing", offenders };
+ },
+
+ // Text actually painted outside the box meant to contain it — which on a table of
+ // overlapping seats means painted on top of a neighbour.
+ //
+ // The clipping check is the whole subtlety, and the first version of this got it
+ // wrong. A Range's bounding rect measures the *laid out* text, which happily runs
+ // past the element when `overflow: hidden` is quietly clipping it — so an
+ // ellipsised name was reported as spilling when nothing was drawn outside at all.
+ // Only overflow that is actually visible can paint over a neighbour.
+ textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") {
+ const offenders = [];
+ [...document.querySelectorAll(selector)].forEach(el => {
+ const text = (el.textContent || "").trim();
+ if (!text) return;
+
+ const style = getComputedStyle(el);
+ const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible";
+ if (clipsItself) return;
+
+ const container = el.closest(".seat") || el.parentElement;
+ if (!container) return;
+
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const drawn = range.getBoundingClientRect();
+ const bounds = container.getBoundingClientRect();
+ if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) {
+ offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`);
+ }
+ });
+ return { name: "no text painted outside its box", offenders };
+ },
+
+ horizontalOverflow() {
+ const offenders = document.documentElement.scrollWidth > innerWidth + 1
+ ? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`]
+ : [];
+ return { name: "page never scrolls sideways", offenders };
+ },
+
+ // iOS zooms the whole page when a field under 16px takes focus, and the dealer
+ // is then pinching back out mid-hand.
+ fieldFontSize() {
+ const offenders = [...document.querySelectorAll("input, textarea, select")]
+ .filter(el => el.offsetParent !== null)
+ .filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || ""))
+ .filter(el => parseFloat(getComputedStyle(el).fontSize) < 16)
+ .map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`);
+ return { name: "no field small enough to trigger iOS zoom", offenders };
+ },
+
+ // ---- Reach (touch target sizes) ----
+
+ touchTargets(minimum = 44) {
+ const offenders = this.visibleControls()
+ .filter(el => el.type !== "color")
+ .filter(el => {
+ const rect = el.getBoundingClientRect();
+ return rect.height < minimum - 0.5 || rect.width < minimum - 0.5;
+ })
+ .map(el => {
+ const rect = el.getBoundingClientRect();
+ return `${this.describe(el)} :: ${Math.round(rect.width)}x${Math.round(rect.height)}`;
+ });
+ return { name: `touch targets at least ${minimum}px`, offenders };
+ },
+
+ // ---- Runner ----
+
+ runAll(options = {}) {
+ const checks = [
+ this.touchAction(),
+ this.tapHighlight(),
+ this.textSelectable(),
+ this.occlusion(options.occlusionSelector),
+ this.collapsedColumns(),
+ this.textSpill(),
+ this.horizontalOverflow(),
+ this.fieldFontSize(),
+ ];
+ if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
+
+ return {
+ viewport: `${innerWidth}x${innerHeight}`,
+ failed: checks.filter(c => c.offenders.length > 0),
+ // Things worth knowing that aren't faults — see occlusion() on toasts.
+ warnings: checks.flatMap(c => c.warnings || []),
+ passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
+ clean: checks.every(c => c.offenders.length === 0),
+ };
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = LayoutAudit;
+}
From 6fa4532781f96944ae2876ed455e1e47060a996c Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 08:39:07 -0600
Subject: [PATCH 6/9] Fix the contrast the audit could finally measure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Contrast is the one check I've got wrong twice, both times by being clever about
backgrounds. The first version read only background-color, so text on any of the
gradient-painted sheets was measured against transparency and the dealer puck's
dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
So this one is built to be incapable of false positives rather than exact.
Backgrounds composite through alpha; where a gradient is involved every colour stop
is tried and the most flattering one used. A finding means the text fails even
under the reading most generous to it.
It's checked against known answers before being believed: black on white at 21:1,
white on white at 1:1, #767676 on white at the 4.54:1 AA boundary, white over a
dark gradient, dark text on that same gradient, white over a 6% white wash on a
near-black page. Nine cases, all correct — and building them found a real bug in
the check, which walked past an opaque gradient and offered the page background as
a candidate. That made it too lenient, which is as bad as the old false positives.
What it found:
Both blind badges failed against their own gradients, in 9px bold text a dealer
reads at a glance across a table. At the worst stop: small blind 3.52:1, big blind
2.15:1. The small blind keeps its blue and darkens to 4.77:1. The big blind flips
to dark text on brighter gold, 5.84:1 — how a gold chip is marked anyway, and it
makes the two badges far easier to tell apart at speed.
Quick Split's footnote names a control the dealer is told to reach for, and that
name inherited the muted grey of the surrounding aside: 3.39:1. Emphasised text
carrying an instruction gets its own colour now.
The felt watermark came back at 1.19:1 and is meant to. It's decoration; holding it
to a reading threshold would mean making it not a watermark. It's aria-hidden now,
which says the same thing where it also does some good, and the audit skips
aria-hidden subtrees.
32 contrast passes over the table, all five overlays and Quick Split at five
viewports: zero findings, zero unmeasured. Layout audit still clean. 137 tests pass.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 2 +-
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 15 +-
.../wwwroot/css/casino/quicksplit.css | 9 ++
tests/holdem/README.md | 10 +-
tests/holdem/uiAudit.js | 153 ++++++++++++++++++
5 files changed, 184 insertions(+), 5 deletions(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 6035869..40c7f2f 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -25,7 +25,7 @@
-
Texas Hold'em
+
Texas Hold'em
Pot
$0
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 9569337..5f2091d 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -621,16 +621,25 @@ button,
display: inline-block;
}
+/* Both badges failed WCAG AA against their own gradients — measured at the worst
+ stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
+ dealer is meant to read at a glance across a table.
+
+ The small blind keeps its blue and just darkens (4.77:1 at its worst stop). The
+ big blind flips to dark text on brighter gold (5.84:1), which is how a gold chip
+ is marked anyway and has the side benefit of making the two badges far easier to
+ tell apart at speed — light-on-blue against dark-on-gold, rather than two pale
+ labels differing only in hue. */
.sbBadge {
left: -6px;
- background: linear-gradient(180deg, #3f82d1, #2b5fa0);
+ background: linear-gradient(180deg, #2f6cb5, #22508c);
color: #eaf3ff;
}
.bbBadge {
left: 22px;
- background: linear-gradient(180deg, #dba13f, #b7791f);
- color: #fff7e6;
+ background: linear-gradient(180deg, #e6ad4d, #c98a2a);
+ color: #241a05;
}
/* A blind the dealer named by hand rather than one the button handed out. Without
diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
index 2cc5a6b..582aa85 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css
@@ -516,6 +516,15 @@ button,
text-align: center;
}
+/* This footnote names a control the dealer is being told to reach for, so the name
+ itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey
+ the surrounding sentence is deliberately set in. Emphasised text carrying the
+ instruction gets its own colour rather than the aside's. */
+.splitFootnote strong {
+ color: #a8a8a8;
+ font-weight: 600;
+}
+
.splitFootnote a {
color: #a9782f;
}
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index 7c865b9..3cfefc0 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -31,7 +31,7 @@ in a browser: whether a dealer can actually reach every control.
node tests/holdem/runUiAudit.js # needs Playwright and the site running
```
-It checks touch-action and tap highlights, that fields stay selectable and are large
+It checks WCAG AA contrast, touch-action and tap highlights, that fields stay selectable and are large
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
@@ -43,6 +43,14 @@ this one needs a browser and a running site. Run it after any change that adds o
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
call `LayoutAudit.runAll()`.
+The contrast check is built so it cannot report a false positive: backgrounds
+composite through alpha, and where a gradient is involved every colour stop is tried
+and the most flattering one used. A finding means the text fails even under the
+reading most generous to it. Two earlier attempts at this got it wrong in both
+directions — one read only `background-color` and scored text on a gradient against
+nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
+against known-answer cases before it is believed.
+
It exists because these checks were being re-typed into a console every time
something changed, and each time they only covered whatever had just been touched.
That is how the standings table shipped with a player-name column zero pixels wide
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 7768ddd..07dcd50 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -231,6 +231,159 @@ const LayoutAudit = {
return { name: "no field small enough to trigger iOS zoom", offenders };
},
+ // ---- Contrast ----
+ //
+ // This check has been got wrong twice before, both times by being too clever about
+ // backgrounds. The first version read only `background-color`, so every sheet
+ // painted with a gradient was measured against transparency and the dealer puck's
+ // dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
+ // treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
+ //
+ // So it is built to be incapable of false positives instead of trying to be exact.
+ // Backgrounds composite properly through alpha, and where a gradient is involved
+ // every one of its colour stops is tried and the *most flattering* one is used. A
+ // finding therefore means the text fails even under the reading most generous to
+ // it, which is a claim worth making. Text that can't be resolved to any opaque
+ // background is reported separately as unmeasured rather than guessed at.
+
+ parseColour(value) {
+ const match = String(value).match(/rgba?\(([^)]+)\)/);
+ if (!match) return null;
+ const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
+ },
+
+ // Colour stops out of any gradient in a background-image.
+ gradientStops(backgroundImage) {
+ if (!backgroundImage || backgroundImage === "none") return [];
+ return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
+ .map(c => this.parseColour(c))
+ .filter(Boolean);
+ },
+
+ over(top, bottom) {
+ const a = top.a;
+ return {
+ r: top.r * a + bottom.r * (1 - a),
+ g: top.g * a + bottom.g * (1 - a),
+ b: top.b * a + bottom.b * (1 - a),
+ a: 1,
+ };
+ },
+
+ relativeLuminance({ r, g, b }) {
+ const channel = v => {
+ const s = v / 255;
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+ },
+
+ contrastRatio(fg, bg) {
+ const a = this.relativeLuminance(fg);
+ const b = this.relativeLuminance(bg);
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
+ },
+
+ // Every background this text could actually be sitting on.
+ //
+ // Layers are collected from the element outwards and stop at the first fully
+ // opaque one, because nothing behind that is visible. Getting this wrong in the
+ // lenient direction is just as bad as the old false positives: an earlier version
+ // kept walking past an opaque gradient and offered the page background as a
+ // candidate, which scored white-on-a-mid-grey-panel as though it were
+ // white-on-near-black and would have waved real problems through.
+ //
+ // A gradient contributes one candidate per colour stop; translucent layers above
+ // the opaque one are composited onto each candidate in order.
+ backgroundCandidates(el) {
+ const translucent = []; // top-down, above the opaque layer
+ let base = null; // candidate colours for the first opaque layer
+
+ for (let node = el; node && !base; node = node.parentElement) {
+ const style = getComputedStyle(node);
+ const stops = this.gradientStops(style.backgroundImage);
+ const colour = this.parseColour(style.backgroundColor);
+
+ // A gradient whose stops are all opaque hides everything behind it.
+ if (stops.length > 0 && stops.every(s => s.a === 1)) {
+ base = stops;
+ break;
+ }
+ stops.forEach(s => { if (s.a > 0) translucent.push(s); });
+
+ if (colour && colour.a === 1) { base = [colour]; break; }
+ if (colour && colour.a > 0) translucent.push(colour);
+ }
+
+ if (!base) {
+ // Nothing opaque anywhere up the tree — fall back to the page's own colour
+ // so a finding is still measured against something real.
+ const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
+ base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
+ }
+
+ // Fold the translucent layers back on, innermost last so it lands on top.
+ return base.map(candidate => {
+ let composed = candidate;
+ for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
+ return composed;
+ });
+ },
+
+ contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
+ const offenders = [];
+ const unmeasured = [];
+
+ const nodes = selector
+ ? [...document.querySelectorAll(selector)]
+ : [...document.querySelectorAll("*")];
+
+ nodes.forEach(el => {
+ if (el.children.length > 0) return; // leaf text only
+ const text = (el.textContent || "").trim();
+ if (!text) return;
+ const rect = el.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const style = getComputedStyle(el);
+ if (style.visibility === "hidden" || style.display === "none") return;
+ if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
+ // Decoration is exempt by definition: the watermark on the felt is meant
+ // to be barely there, and holding it to a reading threshold would mean
+ // making it not a watermark. Marked aria-hidden so screen readers skip it
+ // too, which is the same claim expressed where it also does some good.
+ if (el.closest("[aria-hidden='true']")) return;
+
+ const fgRaw = this.parseColour(style.color);
+ if (!fgRaw) return;
+
+ const candidates = this.backgroundCandidates(el);
+ if (candidates.length === 0) {
+ unmeasured.push(`${this.describe(el)} :: no opaque background found`);
+ return;
+ }
+
+ // The kindest reading available: best contrast across every candidate.
+ let best = 0;
+ candidates.forEach(bg => {
+ const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
+ best = Math.max(best, this.contrastRatio(fg, bg));
+ });
+
+ const sizePx = parseFloat(style.fontSize);
+ const weight = parseInt(style.fontWeight, 10) || 400;
+ const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
+ const required = isLarge ? largeMinimum : minimum;
+
+ if (best + 0.05 < required) {
+ offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
+ }
+ });
+
+ return { name: "text meets WCAG AA contrast", offenders, unmeasured };
+ },
+
// ---- Reach (touch target sizes) ----
touchTargets(minimum = 44) {
From d66abda05adcb9f87fc396253b8e90d651afda6c Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 08:54:07 -0600
Subject: [PATCH 7/9] Make the dealer puck hittable without making it bigger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ran the touch-target check that had been sitting in the audit unused. The dealer
puck is 22x22 — half what a fingertip needs — in ten places on the table, and
tapping it hands the button to that seat and re-posts the blinds. Not a cosmetic
control.
It stays 22x22. A puck should look like a puck sitting on the rim of its seat, and
growing it would take area from the seat itself, which a dealer taps far more
often. Instead an invisible pad around it catches the tap: 43x44 hittable on every
phone size, close to four times the area, with the pad pushed down and left away
from the seat the chip juts towards. Verified that no seat tap is stolen — the
occlusion audit is clean at every size.
Three things the check itself got wrong, all found before acting on them
It read element boxes, and boxes lie in both directions. A control padded out with
a transparent ::after responds well past its painted size, and a checkbox wrapped
in a label is hit by tapping anywhere on the label — so it reported the dedicated
dealer toggle as a 24px fault when it sits inside a 320x44 label that already
forwards the tap. It probes outward with elementFromPoint now, which is what a
finger actually experiences.
The probe walked a fixed 40px each way, so everything wider than 80px reported
exactly 80. Every header button came back a suspiciously round "80x42".
And it enforced 44px on desktop, where there is no fingertip. Those same header
buttons are 64px tall on a phone and 42px with a mouse, which is not a defect.
Keyed off hover:none now, the same signal used for the tablet field sizing.
Also removed a ::after pad on the dedicated-dealer checkbox that was both inert --
pseudo-elements don't render on replaced elements -- and unnecessary, since the
label already does the job.
The puck is the one documented exemption: it is deliberately small and "Give the
button" is in the seat menu at full size, so it's a shortcut rather than the only
way through. The reason sits in the code next to the exemption.
Phone sizes: dealer chip 22x22 drawn / 43x44 hittable, zero undersized controls
even with the exemption and the pointer rule forced off. Layout and contrast
audits clean at all seven sizes. 137 tests passing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 27 +++++++
tests/holdem/README.md | 8 +-
tests/holdem/uiAudit.js | 84 +++++++++++++++++++--
3 files changed, 110 insertions(+), 9 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 5f2091d..9bb7fbd 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -582,6 +582,23 @@ button,
z-index: 2;
opacity: 0.32;
transition: opacity 0.15s ease;
+ /* A dealer puck should look like a puck — small, sitting on the rim of its seat.
+ But tapping it hands the button to that seat and re-posts the blinds, and 22px
+ is half the 44px a fingertip needs. The hit area grows without the chip doing
+ so: the visual stays 22px and an invisible pad around it catches the tap.
+ Deliberately not centred on the chip — it's pushed down and left, away from the
+ neighbouring seat the chip juts towards, so the bigger target can't start
+ stealing taps meant for the seat next door. */
+ isolation: isolate;
+}
+
+.dealerChip::after {
+ content: "";
+ position: absolute;
+ top: -9px;
+ right: -7px;
+ bottom: -15px;
+ left: -14px;
}
.dealerChip:focus-visible {
@@ -2155,6 +2172,10 @@ body.hasActionBar #toastHost {
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
+ /* Same treatment as the dealer puck: a 30px dismiss button in a toast a dealer is
+ swatting away mid-hand, given a fingertip's worth of hit area without becoming
+ a bigger piece of furniture. */
+ position: relative;
}
@media (hover: hover) {
@@ -2872,3 +2893,9 @@ textarea:focus-visible {
padding: 0.4rem 0.6rem;
}
}
+
+.toastClose::after {
+ content: "";
+ position: absolute;
+ inset: -7px;
+}
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index 3cfefc0..10ad8f7 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -31,7 +31,7 @@ in a browser: whether a dealer can actually reach every control.
node tests/holdem/runUiAudit.js # needs Playwright and the site running
```
-It checks WCAG AA contrast, touch-action and tap highlights, that fields stay selectable and are large
+It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
@@ -51,6 +51,12 @@ directions — one read only `background-color` and scored text on a gradient ag
nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
against known-answer cases before it is believed.
+Touch targets are measured by probing outward with `elementFromPoint` rather than by
+reading the element box, because the box lies in both directions: a small control can
+be padded out with a transparent `::after` and respond well beyond its painted size,
+and a checkbox inside a label is hit by tapping the label. The 44px rule is only
+enforced where `hover: none` says there's no mouse.
+
It exists because these checks were being re-typed into a console every time
something changed, and each time they only covered whatever had just been touched.
That is how the standings table shipped with a player-name column zero pixels wide
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 07dcd50..98e91e3 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -386,16 +386,84 @@ const LayoutAudit = {
// ---- Reach (touch target sizes) ----
- touchTargets(minimum = 44) {
- const offenders = this.visibleControls()
+ // What a fingertip can actually hit, which is not the same as the element's box.
+ //
+ // Two things make the box misleading, and the first version of this check was
+ // fooled by both. A control can be padded out with a transparent ::after, so it
+ // responds well beyond its painted size — that's how a 22px dealer puck should
+ // stay 22px and still take a thumb. And a checkbox wrapped in a label is hit by
+ // tapping anywhere on the label, so a 24px box inside a 320x44 label is a
+ // perfectly good target. Measuring the box reported both as faults.
+ //
+ // So it probes outwards with elementFromPoint until the point stops landing on the
+ // control, which is the thing a finger actually experiences.
+ effectiveTarget(el, limit = 48) {
+ const rect = el.getBoundingClientRect();
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ // The probe walks outward a fixed distance, so it can only ever report
+ // 2 x limit. Anything already drawn larger than that reads as exactly the cap
+ // — which had every header button reporting a suspiciously round 80px. Give
+ // the reach room for the element itself plus the padding being looked for.
+ limit = Math.max(limit, rect.width / 2 + 24, rect.height / 2 + 24);
+
+ // A wrapping or associated label forwards the tap, so it counts as the target.
+ const label = el.closest("label") || (el.labels && el.labels[0]);
+ const lands = (x, y) => {
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
+ const hit = document.elementFromPoint(x, y);
+ if (!hit) return false;
+ if (hit === el || el.contains(hit)) return true;
+ return !!(label && (hit === label || label.contains(hit)));
+ };
+
+ if (!lands(cx, cy)) return null; // centre isn't even hittable; occlusion's problem
+
+ const reach = (dx, dy) => {
+ let far = 0;
+ for (let d = 1; d <= limit; d++) {
+ if (!lands(cx + dx * d, cy + dy * d)) break;
+ far = d;
+ }
+ return far;
+ };
+ return { width: reach(-1, 0) + reach(1, 0), height: reach(0, -1) + reach(0, 1) };
+ },
+
+ // The 44px guideline is about fingertips, so it only bites where there's no mouse.
+ // Enforcing it everywhere flagged the header buttons at 42px tall on a desktop,
+ // which is not a defect — the same buttons are 64px tall on a phone, where it
+ // matters. Same reasoning as the field sizing: `hover: none` is what actually
+ // distinguishes a finger from a pointer, not viewport width.
+ //
+ // `exempt` is for controls that are deliberately small *and* have a full-size path
+ // to the same action. The dealer puck is the only one: it's a 22px chip sitting on
+ // the rim of a seat, and growing it to 44px would take that area from the seat
+ // itself — which a dealer taps far more often. "Give the button" is in the seat
+ // menu at full size, so the puck is a shortcut, not the only way through.
+ touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
+ const offenders = [];
+ if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
+ return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
+ }
+ this.visibleControls()
.filter(el => el.type !== "color")
- .filter(el => {
+ .filter(el => !(exempt && el.matches(exempt)))
+ .forEach(el => {
const rect = el.getBoundingClientRect();
- return rect.height < minimum - 0.5 || rect.width < minimum - 0.5;
- })
- .map(el => {
- const rect = el.getBoundingClientRect();
- return `${this.describe(el)} :: ${Math.round(rect.width)}x${Math.round(rect.height)}`;
+ // Drawn big enough is big enough — no need to probe, and probing wide
+ // elements is where the cap above used to produce nonsense. Seats sit
+ // here: they're 76x72 and overlap each other at the rim by design, so
+ // measuring their reach would report the crowding as a fault.
+ if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
+
+ const size = this.effectiveTarget(el);
+ if (!size) return;
+ if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
+ offenders.push(
+ `${this.describe(el)} :: hittable ${size.width}x${size.height}` +
+ ` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
+ );
});
return { name: `touch targets at least ${minimum}px`, offenders };
},
From e0eb0d2079d01cd719cf1f390ea942805a454ff2 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 09:12:17 -0600
Subject: [PATCH 8/9] Say something when the raise isn't a legal raise
Web search turned up the oversized-chip rule described as the single most common
cause of floor disputes, and specifically a home-game problem. Chasing that led to
checking what the bar does with an illegal raise, and the answer was: nothing.
With the minimum at $18 and a player holding $200, typing 13 was accepted without
a word. The engine then treated it as a short raise and correctly declined to
reopen the betting -- right for an all-in, wrong for somebody who just misheard
the bet, and either way the dealer was never told.
The raise entry now says what's wrong and still lets it through. This tool records
what the humans at the table actually did, and a dealer overruling the arithmetic
is right more often than the arithmetic is, so Confirm stays enabled in every case:
$13 is under the $18 minimum. A legal raise is $18 or more -- otherwise
it's a call, or all-in.
All-in for less than a full raise. Legal, but it doesn't reopen the
betting -- anyone who has already acted owes the difference or folds.
More than they have. This goes in as all-in for $200.
$10 is not above the $10 already bet -- that's a call, not a raise.
The second one surfaces the rule fixed two rounds ago at the moment it decides
something, rather than only in the rulings sheet.
Two minimums, which is where the first attempt went wrong
The pad already had a minimum, clamped to what the player can actually put in --
correct for the Min button, wrong for the warnings. For a short stack the clamped
minimum equals their all-in, so neither warning could ever fire for exactly the
player the rule exists for. Kept separate now: one for the button, the rules one
for the warnings.
A regression the audit caught, in the same round that introduced it
The note makes the raise pad taller, --actionBarHeight grows, and the toast --
which floats just above the bar since the occlusion round -- got pushed up over
the table. Measured at 360x640: bar 122px closed, 178px with the pad, 217px with a
note, and the toast climbing from y506 to y411, covering seats outright.
A toast is transient news about the action just taken; the pad is the dealer
deciding the next one. The older one gets out of the way when the pad opens --
nothing is lost, its Undo is also the bar's own undo control. The note is tighter
on short screens, and the pot breakdown hides during entry, since which side pots
exist isn't what's being read while typing an amount. Net: 210px rather than 217px,
and no seat covered that the pad wasn't already covering at 178px.
Two rulings entries, from the same search
The oversized chip -- a single big chip pushed out silently is a call, not a raise,
however big it is; to raise you say so before it lands. And what a full raise is,
which the pad now enforces in words.
137 tests passing. Raise warnings verified across four amounts on a deep stack and
three on a short one, at eight viewports: note shows, pad stays reachable, contrast
clean, Confirm never disabled. Toast still behaves normally when the pad is closed.
Sources: cardplayer.com/.../contracts-and-poker-the-oversized-chip-rule,
pokernews.com/strategy/be-careful-when-betting-essential-wsop-rules-to-remember
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 27 +++++++
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 40 +++++++++
.../wwwroot/js/casino/holdem/actionBar.js | 81 ++++++++++++++++++-
3 files changed, 147 insertions(+), 1 deletion(-)
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 40c7f2f..36c00b3 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -340,6 +340,33 @@
+
+ One big chip, nothing said
+
+ The most common argument at a home table. A player facing a bet who
+ pushes out a single oversized chip without saying anything has
+ called — not raised, however big the chip is. To raise with one
+ chip you have to say "raise" before it lands; say it without an amount
+ and the raise is the most that chip allows.
+
+
+ Facing no bet, the same silent chip is a bet of the whole chip. And a
+ clear verbal declaration in turn is binding either way — if words and
+ chips disagree, whichever came first is the action.
+
+
+
+
+ A raise has to be a full raise
+
+ At least as much again as the last raise, or the big blind if there
+ hasn't been one. Anything between a call and that minimum isn't a raise
+ a player can make — it's a call, unless they're putting their last chips
+ in. The raise pad says so if you type one, and won't stop you: your
+ ruling beats the arithmetic.
+
+
+
The odd chip in a split pot
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 9bb7fbd..d6ee36f 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2899,3 +2899,43 @@ textarea:focus-visible {
position: absolute;
inset: -7px;
}
+
+/* A note under the raise entry when the amount typed isn't a legal raise. It informs
+ and never blocks: this tool records what the humans at the table actually did, and
+ a dealer overruling the arithmetic is right more often than the arithmetic is.
+ Under-minimum raises are the single most common thing that stops a home game, and
+ the bar used to accept one without a word. */
+.raiseNote {
+ margin: 0.45rem 0 0;
+ font-size: 0.74rem;
+ line-height: 1.35;
+ text-align: center;
+}
+
+.raiseNote.raiseNoteWarn {
+ color: #f0b46a;
+}
+
+.raiseNote.raiseNoteInfo {
+ color: #9fc7e8;
+}
+
+/* On a short phone the raise pad already makes the bar tall enough to cover the
+ lowest seats, and the note must not make that materially worse. Measured at
+ 360x640: bar 122px closed, 178px with the pad open, 217px with a note. Tightened
+ here so the note costs a line rather than a paragraph — the bar is a transient
+ input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */
+@media (max-height: 700px) {
+ .raiseNote {
+ margin-top: 0.3rem;
+ font-size: 0.68rem;
+ line-height: 1.25;
+ }
+
+ /* The pot breakdown is reference material — which side pots exist and who can win
+ them. While an amount is being typed it isn't what the dealer is reading, and
+ it's the tallest thing competing for the strip. */
+ body.hasActionBar #actionBar:has(.raisePad) .potBreakdown {
+ display: none;
+ }
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 6029bcc..044c895 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -319,12 +319,26 @@ const ActionBar = {
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
// button, and only reaches for digits when the table does something odd.
renderRaisePad() {
+ // A toast is transient news about the action just taken; the raise pad is the
+ // dealer deciding the next one. On a short screen they compete for the same
+ // strip: the pad makes the bar 217px tall on a 640px phone, and the toast —
+ // which floats just above the bar — gets pushed up over the table, covering
+ // seats outright. Rather than squeeze both in, the older one gets out of the
+ // way. Nothing is lost: the toast's Undo is also the bar's own undo control.
+ if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
+
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
const pot = Betting.potSoFar(this.state);
- const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
+ // Two different minimums, and conflating them was a bug. `minTo` is clamped to
+ // what this player can actually put in, which is what the Min button should
+ // offer. `legalMin` is what the rules require — for a short stack the two are
+ // different, and it's the rules one the warnings have to be measured against,
+ // or a player whose whole stack is under a full raise never gets told that.
+ const legalMin = Betting.minRaiseTo(this.state);
+ const minTo = Math.min(legalMin, maxTo);
const wrap = document.createElement("div");
wrap.className = "raisePad";
@@ -360,6 +374,7 @@ const ActionBar = {
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
+ this.updateRaiseNote(wrap, legalMin, maxTo);
});
entry.appendChild(input);
@@ -376,9 +391,73 @@ const ActionBar = {
}));
wrap.appendChild(entry);
+
+ const note = document.createElement("p");
+ note.className = "raiseNote";
+ note.hidden = true;
+ wrap.appendChild(note);
+ this.updateRaiseNote(wrap, legalMin, maxTo);
+
return wrap;
},
+ // Says something when the amount typed isn't a legal raise. It never blocks —
+ // a dealer overruling the arithmetic is right more often than the arithmetic is,
+ // and the whole tool is built on tapping what the humans actually did.
+ //
+ // But an under-minimum raise from a player with chips behind them is the single
+ // most common thing that stops a home game dead, and until now the bar took it
+ // without a word: type 13 when the minimum is 18 and it was simply accepted. The
+ // engine then treats it as a short raise and correctly doesn't reopen the betting,
+ // which is right for an all-in and wrong for somebody who just misheard the bet.
+ updateRaiseNote(wrap, minTo, maxTo) {
+ const note = wrap.querySelector(".raiseNote");
+ if (!note) return;
+
+ const typed = Number(this.raiseDraft);
+ const current = this.state.currentBet || 0;
+
+ if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
+
+ // Going all-in for less than a full raise is perfectly legal, and worth saying
+ // out loud because of what follows from it.
+ const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
+
+ if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
+ note.textContent =
+ `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
+ note.className = "raiseNote raiseNoteWarn";
+ note.hidden = false;
+ return;
+ }
+
+ if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
+ note.textContent =
+ `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
+ note.className = "raiseNote raiseNoteInfo";
+ note.hidden = false;
+ return;
+ }
+
+ if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; }
+
+ if (typed > maxTo + Betting.EPSILON_CHIPS) {
+ note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`;
+ note.className = "raiseNote raiseNoteInfo";
+ note.hidden = false;
+ return;
+ }
+
+ if (typed <= current) {
+ note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`;
+ note.className = "raiseNote raiseNoteWarn";
+ note.hidden = false;
+ return;
+ }
+
+ note.hidden = true;
+ },
+
renderStreetDone() {
const row = document.createElement("div");
row.className = "actionButtons";
From a72f1991393388c7b685bcf6532e805215fa501b Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Tue, 1 Sep 2026 09:20:46 -0600
Subject: [PATCH 9/9] Light up the seat that's holding the game up
Asked what actually slows a home game down and the answer from people who deal
them is consistent: not how fast the cards come out, but players not noticing the
action is on them. "Waiting for shuffling and players not realizing it's their
action is going to cost much more time than how quickly you spread the flop."
So I measured how obvious the acting seat is, and it was distinguished by a rim
colour and nothing else -- a gold border and a 2px ring where a live seat has a
green border and a 1px one. Same fill, same size, nothing moving. That is a subtle
thing to ask a dealer to spot across a table on a phone, for the one state the
whole pace of the game hangs on.
It reads as lit now rather than merely outlined: a solid gold ring at full
strength, a warm glow separating it from the felt, a warmer fill, and it sits
above its neighbours so the ring is never clipped by the seat next to it.
There's also a slow two-and-a-half second breath, which stops entirely under
prefers-reduced-motion -- the ring carries the whole job on its own for anyone who
would rather nothing moved, and that was checked rather than assumed.
The lift is box-shadow, not transform. A seat's transform is what positions it
around the felt, so setting one here would throw it off its spot -- the same trap
that nearly shipped a 38px seat displacement a few rounds ago.
One measurement I'm not claiming: the acting and live fills differ by only 1.1:1,
because the change is a hue shift rather than a lightness one. The ring and the
glow do the work; the fill just stops it looking like the same chip.
Checked at eight viewports: layout and contrast audits clean, exactly one acting
seat everywhere, and the lit seat is the same size as its neighbours at every
size -- the ring adds no layout. renderAll is 1.8ms with the pulse running, so it
costs nothing. Text on the warmer fill still passes contrast at every size.
137 tests passing.
Sources: pokerchipforum.com/threads/as-a-dealer-how-do-increase-more-hands-per-hour.114914/,
forumserver.twoplustwo.com/27/casino-amp-cardroom-poker/tips-advice-faster-better-dealer-241644/
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 46 +++++++++++++++++++--
1 file changed, 42 insertions(+), 4 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index d6ee36f..b413bb0 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2029,12 +2029,50 @@ body.hasActionBar #holdemSidebar {
color: #f0d9a8;
}
-/* Whose turn it is, shown on the table itself as well as in the bar. */
+/* Whose turn it is, shown on the table itself as well as in the bar.
+
+ Dealers say the biggest single time sink at a home table isn't how fast the cards
+ come out — it's players not noticing the action is on them. So this is the one
+ state that has to be unmistakable from across a table at a glance, and it was
+ distinguished only by a rim colour: a gold border and a 2px ring where a live seat
+ has a green border and a 1px one. Same fill, same size, nothing moving.
+
+ It now reads as lit rather than merely outlined: a solid ring at full strength, a
+ warm glow that separates it from the felt, and a lift of its own. The seat's
+ `transform` positions it around the table, so the lift is box-shadow rather than
+ translate — setting transform here would throw the seat off its spot. */
.seat.seatActing {
- border-color: #d8b46a;
+ border-color: #f0cd85;
+ background: linear-gradient(160deg, #3a3222, #241f16);
box-shadow:
- 0 0 0 2px rgba(216, 180, 106, 0.45),
- 0 4px 16px rgba(0, 0, 0, 0.55);
+ 0 0 0 3px rgba(216, 180, 106, 0.9),
+ 0 0 18px 2px rgba(216, 180, 106, 0.35),
+ 0 6px 20px rgba(0, 0, 0, 0.6);
+ z-index: 4;
+}
+
+/* A slow breath, so a dealer glancing up finds it without hunting. Deliberately
+ gentle and deliberately optional — the ring above carries the whole job on its own
+ for anyone who'd rather nothing moved. */
+@media (prefers-reduced-motion: no-preference) {
+ .seat.seatActing {
+ animation: seatActingPulse 2.4s ease-in-out infinite;
+ }
+
+ @keyframes seatActingPulse {
+ 0%, 100% {
+ box-shadow:
+ 0 0 0 3px rgba(216, 180, 106, 0.9),
+ 0 0 18px 2px rgba(216, 180, 106, 0.35),
+ 0 6px 20px rgba(0, 0, 0, 0.6);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 3px rgba(216, 180, 106, 0.65),
+ 0 0 26px 5px rgba(216, 180, 106, 0.5),
+ 0 6px 20px rgba(0, 0, 0, 0.6);
+ }
+ }
}
.seatPuckStack {