Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 42b9e83e8f Don't deal the hand when the dealer names a blind
The seat menu's "Make small blind" / "Make big blind" / "Blinds follow the
button again" all re-posted the blinds whenever `Betting.onlyBlindsIn(state)`
was true. That condition means "nothing has happened in this hand except the
blinds" — which is true of a hand one second old and equally true of a table
sitting in setup with no cards out and nothing committed.

So naming a blind before the deal ran repostBlinds -> beginHand: betting
opened, two stacks docked, the action bar up and waiting on the seat after the
big blind. The dealer had said where the blinds go and the tool heard "deal".

Betting.blindsArePosted() is the question these three actually wanted — the
blinds are down only once a hand is open — and it's the guard makeDealer was
already using by hand. Before the deal, naming a blind now moves a badge and
nothing else; the toast says "Dee has the small blind next hand." rather than
claiming she posted it. Mid-hand behaviour is unchanged: chips come back and
go in again from the new positions, and the chip count stays straight.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 11:11:34 -06:00
jheaps 470fbc0d26 Add the seat-name round to the release 2026-09-02 09:52:35 -06:00
jheapsandClaude Opus 5 c4e842f3e3 Stop the button's chip sitting on top of the name underneath it
A phone screenshot, mid-hand, eight-handed: the seat with the button reads
"Christop" and then a white D. The name is centred across the whole puck and the
chip sits in its top-right corner on a layer above, so a long name runs
underneath and stops — measured, "Christopher" lost 20px of its 64px box.

The bad part is the silence. The text believed it had the room it was being
painted out of, so no ellipsis appeared: nothing on screen said the name went on.
On the one seat at the table a dealer looks at most.

The name now keeps out of the chip's corner on the seat that has the button, so a
long one ends in an ellipsis before it gets there. Only that seat — every other
puck has its full width, because the chip on those is invisible.

And the audit has a check for it now. Nothing it had would have caught this: the
occlusion check is about controls a thumb has to reach, and this is a label a
dealer has to read. The new one measures every seat's name against its own chip
and badges, and the fix was undone by hand to confirm it fires — "20px under
button.dealerChip.active".

182 audits across 13 viewports, both input kinds, plain and straddled tables,
every overlay: clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:52:00 -06:00
jheaps 1d9353c94e Add the hidden-means-hidden round to the release 2026-09-02 09:37:29 -06:00
jheaps 6174981cdc Add the assumed-contributions round to the release 2026-09-02 09:21:57 -06:00
jheaps 0ad5592687 Add the one-stack-rule round to the release 2026-09-02 09:06:07 -06:00
jheaps 236c52567e Add the starting-stack accounting round to the release 2026-09-02 08:53:35 -06:00
jheaps b8655be258 Add the waiting-list round to the release 2026-09-02 08:38:21 -06:00
jheaps 7e420ce747 Add the misdeal round to the release 2026-09-02 08:24:05 -06:00
jheaps c36d7f60bd Add the cash-out round to the release 2026-09-02 08:09:26 -06:00
jheaps 15cec18256 Add the clock round to the release 2026-09-02 05:40:45 -06:00
jheaps 9c8d981e2d Add the house-take round to the release 2026-09-02 05:13:11 -06:00
jheaps 907e0165db Add the stadium ring round to the release 2026-09-02 04:59:37 -06:00
jheaps 86e1570618 Add the phone felt round to the release 2026-09-02 04:34:05 -06:00
jheaps 6df400322e Add the audit round to the release 2026-09-02 04:03:25 -06:00
jheaps ca9ac7b6af Add the straddle round to the release 2026-09-02 03:32:58 -06:00
jheaps 80dae56f0d Add the landscape round to the release 2026-09-02 03:13:05 -06:00
jheaps 6ca99fde6b Bring master's already-merged rounds into the release branch
Master merged #58-#61 (manual deal, less guessing, seat editor, landscape
table) while the stack kept going. Those commits are already in this branch's
history; this merge just reconciles the two shapes so the release can go in as
one.
2026-09-02 01:19:56 -06:00
jheaps b2262f8604 Merge pull request 'Wait to be told to deal, instead of guessing' (#58) from fix/casino-holdem-manual-deal into master
Build and Deploy / build-deploy (push) Successful in 1m10s
Reviewed-on: #58
2026-09-01 10:57:23 -06:00
jheaps 6ad9923aae Merge pull request 'Stop the last two things the table did behind the dealer's back' (#59) from fix/casino-holdem-less-guessing into fix/casino-holdem-manual-deal
Reviewed-on: #59
2026-09-01 10:57:17 -06:00
jheaps 168e8a380c Merge pull request 'Tapping a seat gives you that seat' (#60) from feature/casino-holdem-seat-editor into fix/casino-holdem-less-guessing
Reviewed-on: #60
2026-09-01 10:57:10 -06:00
jheaps edc08d7213 Merge pull request 'Let the landscape table use the width it has' (#61) from fix/casino-holdem-landscape-table into feature/casino-holdem-seat-editor
Reviewed-on: #61
2026-09-01 10:57:01 -06:00
6 changed files with 218 additions and 8 deletions
@@ -622,6 +622,14 @@ button,
margin-top: 0.6rem;
}
/* The button's chip sits in this puck's top-right corner, on a layer above the text.
Reserving its width means a long name ends in an ellipsis before it reaches the
chip, rather than disappearing underneath one — on the one seat at the table a
dealer looks at most. */
.seat.seatHasButton .seatPuckName {
padding-right: 22px;
}
.seatPuckCards {
font-size: 0.68rem;
font-weight: 700;
@@ -459,6 +459,25 @@ const Betting = {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Are the blinds actually down? Moving the button or naming a blind means two
// completely different things depending on the answer, and `onlyBlindsIn` alone
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
// committed, satisfies it just as well as a hand that has only just been dealt.
//
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
// those chips back and post them again from the new positions, or the badges and
// the money disagree.
//
// Before the deal it is a marker being slid round a table with nothing in the
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
// two stacks, action on the seat after the big blind — which is not what a dealer
// means by "she's the big blind this one". Setting the positions is how a dealer
// gets ready to deal; the cards come out when they press the button, and not a
// moment before.
blindsArePosted(state) {
return !!state.bettingOpen && this.onlyBlindsIn(state);
},
// Chips the pot has been priced with but nobody has actually paid.
//
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
+18 -8
View File
@@ -176,8 +176,7 @@ document.addEventListener("DOMContentLoaded", () => {
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
const willRepost = Betting.blindsArePosted(state);
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
@@ -204,18 +203,29 @@ document.addEventListener("DOMContentLoaded", () => {
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
const label = which === "sb" ? "small blind" : "big blind";
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
// Only a hand that is already open has blinds to move. Before the deal this
// is aiming the next hand, and re-posting would have dealt it — the dealer
// says who the blind is well before they say "cards".
const posted = Betting.blindsArePosted(state);
seatAction(index, posted
? `${nameOf(index)} posts the ${label}.`
: `${nameOf(index)} has the ${label} next hand.`, () => {
state.blindOverride = which === "sb"
? { sbIndex: index, bbIndex: partner }
: { sbIndex: partner, bbIndex: index };
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
if (posted) Betting.repostBlinds(state);
});
},
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
state.blindOverride = null;
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
clearBlindOverride: () => {
const posted = Betting.blindsArePosted(state);
seatAction(state.dealerSeatIndex, posted
? "Blinds follow the button again — taken back and re-posted."
: "Blinds follow the button again.", () => {
state.blindOverride = null;
if (posted) Betting.repostBlinds(state);
});
},
editSeat: index => Render.toggleSeatEditor(index),
@@ -408,6 +408,12 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
// Marked on the seat as well as the chip, so the puck's own text can keep
// out from under it. A name is centred across the whole puck and the chip
// sits in the corner on a layer above: measured, "Christopher" on the
// button lost 20px of its 64px box to the chip and showed no ellipsis,
// because the text believed it had the room it was painted out of.
node.classList.toggle("seatHasButton", hasButton);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
+128
View File
@@ -0,0 +1,128 @@
// Saying who the blind is, before saying "cards".
//
// A dealer sets the table up out loud — button here, she's the small blind, he's the
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
// the action bar up and waiting on the seat after the big blind. The dealer had said
// where the blinds go, and the tool heard "deal".
//
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
// this hand except the blinds" — true of a hand one second old, and equally true of a
// table in setup with no cards out and nothing committed. Those two need telling apart,
// which is what `blindsArePosted` is for.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("before the deal", () => {
test("the blinds are not down, however much the table looks ready", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(state.bettingOpen, false);
// The old condition, on its own, says yes here — this is the whole bug.
assert.equal(Betting.onlyBlindsIn(state), true);
assert.equal(Betting.blindsArePosted(state), false);
});
test("a table straight out of setup, with nobody seated, is the same answer", () => {
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
});
test("re-posting would have dealt the hand, which is why it is guarded", () => {
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
// setup is the deal, and nothing about naming a blind should reach it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
Betting.repostBlinds(state);
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
assert.notEqual(state.actingSeatIndex, null);
});
test("naming a blind moves a badge and no chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
// What the seat menu does: pin the override, repost only if the blinds are down.
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
assert.equal(chipsOnTable(state), before);
// And the badges say what the dealer just said.
const blinds = HoldemState.getBlindSeats(state);
assert.equal(blinds.sbIndex, 3);
assert.equal(blinds.bbIndex, 0);
});
test("and the hand that follows posts from where they were aimed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
});
test("giving the button away is a marker moving too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
const willRepost = Betting.blindsArePosted(state);
state.dealerSeatIndex = 2;
if (willRepost) Betting.repostBlinds(state);
assert.equal(willRepost, false);
assert.equal(state.bettingOpen, false);
assert.equal(chipsOnTable(state), before);
});
});
describe("once the hand is dealt", () => {
test("the blinds are down, and re-aiming them moves real chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(Betting.blindsArePosted(state), true);
assert.equal(Betting.committed(state.seats[1]), 1);
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
});
test("but not once somebody has answered them", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, state.actingSeatIndex);
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
});
test("nor on a later street", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.street = "flop";
assert.equal(Betting.blindsArePosted(state), false);
});
test("and a misdealt hand goes back to being a table in setup", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.takeHandBack(state);
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
});
test("as does a hand that has been paid out", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
HoldemState.startNextHand(state);
assert.equal(Betting.blindsArePosted(state), false);
});
});
+39
View File
@@ -739,6 +739,44 @@ const LayoutAudit = {
return { name: "hidden means hidden", offenders };
},
// A seat's name, painted under the decoration on its own puck.
//
// The chip that marks the button sits in the puck's top-right corner on a layer
// above the text, and the name is centred across the whole width. A long one
// therefore ran underneath it and stopped — measured, "Christopher" on the button
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
// concerned it had all the room it needed. Nothing else here would have caught it:
// the occlusion check is about controls a thumb has to reach, and this is a label
// a dealer has to read.
seatLabelsClear() {
const offenders = [];
document.querySelectorAll(".seat").forEach(seat => {
const name = seat.querySelector(".seatPuckName");
if (!name || !name.textContent.trim()) return;
const box = name.getBoundingClientRect();
if (!box.width || !box.height) return;
// Padding is room the text has already been kept out of, so it is not
// somewhere the text can be covered.
const style = getComputedStyle(name);
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
const textRight = box.right - parseFloat(style.paddingRight || 0);
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
const over = mark.getBoundingClientRect();
if (!over.width || !over.height) return;
const marked = getComputedStyle(mark);
if (marked.display === "none" || marked.visibility === "hidden" ||
parseFloat(marked.opacity) < 0.05) return;
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
if (covered > 1 && vertical > 1) {
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
}
});
});
return { name: "seat names aren't painted under their own badges", offenders };
},
// ---- Runner ----
// Every check this file knows how to make, every time.
@@ -768,6 +806,7 @@ const LayoutAudit = {
this.seatOwnership(),
this.oneDealerButton(),
this.hiddenButPainted(),
this.seatLabelsClear(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];