Compare commits

..
Author SHA1 Message Date
jheapsandClaude Opus 5 6fa4532781 Fix the contrast the audit could finally measure
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:39:07 -06:00
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
jheapsandClaude Opus 5 60d192a9ec Let the banker check the cash box against the chips on the table
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 07:33:16 -06:00
jheapsandClaude Opus 5 cc6a1902bc Stop the tool from throwing a pot away without saying so
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:39:51 -06:00
14 changed files with 1413 additions and 29 deletions
+60 -9
View File
@@ -25,7 +25,7 @@
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark">Texas Hold'em</div>
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
@@ -104,16 +104,31 @@
<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>
<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">Once you resolve a pot, everyone's running total for the night shows up here.</p>
<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>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
<span id="bankCashIn"></span>
</div>
<div class="bankReconcileRow">
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
<h3 class="panelSubhead">Hands</h3>
<div id="handHistoryList"></div>
@@ -225,6 +240,7 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsCashOut"></span>
</div>
</template>
@@ -299,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.
+147 -7
View File
@@ -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
@@ -1040,6 +1049,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 +1481,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;
grid-template-columns: minmax(4rem, 1fr) auto auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.3rem 0.1rem;
@@ -1478,7 +1507,78 @@ 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
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 {
@@ -2123,7 +2223,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;
}
@@ -2137,8 +2242,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;
}
@@ -2732,3 +2842,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;
}
}
@@ -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;
}
@@ -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;
+21 -2
View File
@@ -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);
});
@@ -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;
@@ -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 ----
@@ -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),
@@ -352,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);
@@ -376,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) {
+34
View File
@@ -22,6 +22,40 @@ 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 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
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()`.
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
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
+169
View File
@@ -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);
});
});
+103
View File
@@ -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");
});
});
+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();
+431
View File
@@ -0,0 +1,431 @@
// 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 };
},
// ---- 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) {
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;
}