Compare commits

..
Author SHA1 Message Date
jheapsandClaude Opus 5 2f6d63400a Stop re-typing the layout audit into a console every round
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:31:35 -06:00
jheapsandClaude Opus 5 0d6e7e0b5a Give the standings table back its player names
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:07:51 -06:00
jheapsandClaude Opus 5 aeb02d023d A short all-in doesn't reopen the betting
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 07:52:08 -06:00
7 changed files with 744 additions and 18 deletions
+45 -8
View File
@@ -104,15 +104,17 @@
<summary>Session</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span>In</span>
<span>Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
</div>
<div id="standingsList"></div>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
@@ -313,6 +315,41 @@
</p>
</section>
<section class="ruling">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
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.
</p>
<p>
A player who hadn't acted yet keeps full rights and may still raise, and
the minimum raise stays measured off the last <em>full</em> raise, not
off the short one. The action bar follows this for you.
</p>
</section>
<section class="ruling">
<h3>Chips nobody called come back</h3>
<p>
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.
</p>
</section>
<section class="ruling">
<h3>The odd chip in a split pot</h3>
<p>
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.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
+93 -4
View File
@@ -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 {
@@ -1465,9 +1472,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 +1498,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
@@ -2165,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;
}
@@ -2179,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;
}
@@ -2774,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;
}
}
@@ -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;
+26
View File
@@ -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
+143
View File
@@ -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");
});
});
+134
View File
@@ -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();
+278
View File
@@ -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;
}