Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 e0eb0d2079 Say something when the raise isn't a legal raise
Web search turned up the oversized-chip rule described as the single most common
cause of floor disputes, and specifically a home-game problem. Chasing that led to
checking what the bar does with an illegal raise, and the answer was: nothing.

With the minimum at $18 and a player holding $200, typing 13 was accepted without
a word. The engine then treated it as a short raise and correctly declined to
reopen the betting -- right for an all-in, wrong for somebody who just misheard
the bet, and either way the dealer was never told.

The raise entry now says what's wrong and still lets it through. This tool records
what the humans at the table actually did, and a dealer overruling the arithmetic
is right more often than the arithmetic is, so Confirm stays enabled in every case:

  $13 is under the $18 minimum. A legal raise is $18 or more -- otherwise
    it's a call, or all-in.
  All-in for less than a full raise. Legal, but it doesn't reopen the
    betting -- anyone who has already acted owes the difference or folds.
  More than they have. This goes in as all-in for $200.
  $10 is not above the $10 already bet -- that's a call, not a raise.

The second one surfaces the rule fixed two rounds ago at the moment it decides
something, rather than only in the rulings sheet.

Two minimums, which is where the first attempt went wrong

The pad already had a minimum, clamped to what the player can actually put in --
correct for the Min button, wrong for the warnings. For a short stack the clamped
minimum equals their all-in, so neither warning could ever fire for exactly the
player the rule exists for. Kept separate now: one for the button, the rules one
for the warnings.

A regression the audit caught, in the same round that introduced it

The note makes the raise pad taller, --actionBarHeight grows, and the toast --
which floats just above the bar since the occlusion round -- got pushed up over
the table. Measured at 360x640: bar 122px closed, 178px with the pad, 217px with a
note, and the toast climbing from y506 to y411, covering seats outright.

A toast is transient news about the action just taken; the pad is the dealer
deciding the next one. The older one gets out of the way when the pad opens --
nothing is lost, its Undo is also the bar's own undo control. The note is tighter
on short screens, and the pot breakdown hides during entry, since which side pots
exist isn't what's being read while typing an amount. Net: 210px rather than 217px,
and no seat covered that the pad wasn't already covering at 178px.

Two rulings entries, from the same search

The oversized chip -- a single big chip pushed out silently is a call, not a raise,
however big it is; to raise you say so before it lands. And what a full raise is,
which the pad now enforces in words.

137 tests passing. Raise warnings verified across four amounts on a deep stack and
three on a short one, at eight viewports: note shows, pad stays reachable, contrast
clean, Confirm never disabled. Toast still behaves normally when the pad is closed.

Sources: cardplayer.com/.../contracts-and-poker-the-oversized-chip-rule,
pokernews.com/strategy/be-careful-when-betting-essential-wsop-rules-to-remember

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:12:17 -06:00
jheapsandClaude Opus 5 d66abda05a Make the dealer puck hittable without making it bigger
Ran the touch-target check that had been sitting in the audit unused. The dealer
puck is 22x22 — half what a fingertip needs — in ten places on the table, and
tapping it hands the button to that seat and re-posts the blinds. Not a cosmetic
control.

It stays 22x22. A puck should look like a puck sitting on the rim of its seat, and
growing it would take area from the seat itself, which a dealer taps far more
often. Instead an invisible pad around it catches the tap: 43x44 hittable on every
phone size, close to four times the area, with the pad pushed down and left away
from the seat the chip juts towards. Verified that no seat tap is stolen — the
occlusion audit is clean at every size.

Three things the check itself got wrong, all found before acting on them

It read element boxes, and boxes lie in both directions. A control padded out with
a transparent ::after responds well past its painted size, and a checkbox wrapped
in a label is hit by tapping anywhere on the label — so it reported the dedicated
dealer toggle as a 24px fault when it sits inside a 320x44 label that already
forwards the tap. It probes outward with elementFromPoint now, which is what a
finger actually experiences.

The probe walked a fixed 40px each way, so everything wider than 80px reported
exactly 80. Every header button came back a suspiciously round "80x42".

And it enforced 44px on desktop, where there is no fingertip. Those same header
buttons are 64px tall on a phone and 42px with a mouse, which is not a defect.
Keyed off hover:none now, the same signal used for the tablet field sizing.

Also removed a ::after pad on the dedicated-dealer checkbox that was both inert --
pseudo-elements don't render on replaced elements -- and unnecessary, since the
label already does the job.

The puck is the one documented exemption: it is deliberately small and "Give the
button" is in the seat menu at full size, so it's a shortcut rather than the only
way through. The reason sits in the code next to the exemption.

Phone sizes: dealer chip 22x22 drawn / 43x44 hittable, zero undersized controls
even with the exemption and the pointer rule forced off. Layout and contrast
audits clean at all seven sizes. 137 tests passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:54:07 -06:00
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
7 changed files with 972 additions and 17 deletions
+38 -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,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>
@@ -338,6 +340,33 @@
</p>
</section>
<section class="ruling">
<h3>One big chip, nothing said</h3>
<p>
The most common argument at a home table. A player facing a bet who
pushes out a single oversized chip without saying anything has
<em>called</em> — not raised, however big the chip is. To raise with one
chip you have to say "raise" before it lands; say it without an amount
and the raise is the most that chip allows.
</p>
<p>
Facing no bet, the same silent chip is a bet of the whole chip. And a
clear verbal declaration in turn is binding either way — if words and
chips disagree, whichever came first is the action.
</p>
</section>
<section class="ruling">
<h3>A raise has to be a full raise</h3>
<p>
At least as much again as the last raise, or the big blind if there
hasn't been one. Anything between a call and that minimum isn't a raise
a player can make — it's a call, unless they're putting their last chips
in. The raise pad says so if you type one, and won't stop you: your
ruling beats the arithmetic.
</p>
</section>
<section class="ruling">
<h3>The odd chip in a split pot</h3>
<p>
+172 -7
View File
@@ -582,6 +582,23 @@ button,
z-index: 2;
opacity: 0.32;
transition: opacity 0.15s ease;
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
But tapping it hands the button to that seat and re-posts the blinds, and 22px
is half the 44px a fingertip needs. The hit area grows without the chip doing
so: the visual stays 22px and an invisible pad around it catches the tap.
Deliberately not centred on the chip — it's pushed down and left, away from the
neighbouring seat the chip juts towards, so the bigger target can't start
stealing taps meant for the seat next door. */
isolation: isolate;
}
.dealerChip::after {
content: "";
position: absolute;
top: -9px;
right: -7px;
bottom: -15px;
left: -14px;
}
.dealerChip:focus-visible {
@@ -621,16 +638,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 +1066,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 +1498,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 +1524,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
@@ -2097,6 +2172,10 @@ body.hasActionBar #toastHost {
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
/* Same treatment as the dealer puck: a 30px dismiss button in a toast a dealer is
swatting away mid-hand, given a fingertip's worth of hit area without becoming
a bigger piece of furniture. */
position: relative;
}
@media (hover: hover) {
@@ -2165,7 +2244,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 +2263,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 +2863,79 @@ 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;
}
}
.toastClose::after {
content: "";
position: absolute;
inset: -7px;
}
/* A note under the raise entry when the amount typed isn't a legal raise. It informs
and never blocks: this tool records what the humans at the table actually did, and
a dealer overruling the arithmetic is right more often than the arithmetic is.
Under-minimum raises are the single most common thing that stops a home game, and
the bar used to accept one without a word. */
.raiseNote {
margin: 0.45rem 0 0;
font-size: 0.74rem;
line-height: 1.35;
text-align: center;
}
.raiseNote.raiseNoteWarn {
color: #f0b46a;
}
.raiseNote.raiseNoteInfo {
color: #9fc7e8;
}
/* On a short phone the raise pad already makes the bar tall enough to cover the
lowest seats, and the note must not make that materially worse. Measured at
360x640: bar 122px closed, 178px with the pad open, 217px with a note. Tightened
here so the note costs a line rather than a paragraph — the bar is a transient
input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */
@media (max-height: 700px) {
.raiseNote {
margin-top: 0.3rem;
font-size: 0.68rem;
line-height: 1.25;
}
/* The pot breakdown is reference material — which side pots exist and who can win
them. While an amount is being typed it isn't what the dealer is reading, and
it's the tallest thing competing for the strip. */
body.hasActionBar #actionBar:has(.raisePad) .potBreakdown {
display: none;
}
}
@@ -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;
}
@@ -319,12 +319,26 @@ const ActionBar = {
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
// button, and only reaches for digits when the table does something odd.
renderRaisePad() {
// A toast is transient news about the action just taken; the raise pad is the
// dealer deciding the next one. On a short screen they compete for the same
// strip: the pad makes the bar 217px tall on a 640px phone, and the toast —
// which floats just above the bar — gets pushed up over the table, covering
// seats outright. Rather than squeeze both in, the older one gets out of the
// way. Nothing is lost: the toast's Undo is also the bar's own undo control.
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
const pot = Betting.potSoFar(this.state);
const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
// what this player can actually put in, which is what the Min button should
// offer. `legalMin` is what the rules require — for a short stack the two are
// different, and it's the rules one the warnings have to be measured against,
// or a player whose whole stack is under a full raise never gets told that.
const legalMin = Betting.minRaiseTo(this.state);
const minTo = Math.min(legalMin, maxTo);
const wrap = document.createElement("div");
wrap.className = "raisePad";
@@ -360,6 +374,7 @@ const ActionBar = {
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
this.updateRaiseNote(wrap, legalMin, maxTo);
});
entry.appendChild(input);
@@ -376,9 +391,73 @@ const ActionBar = {
}));
wrap.appendChild(entry);
const note = document.createElement("p");
note.className = "raiseNote";
note.hidden = true;
wrap.appendChild(note);
this.updateRaiseNote(wrap, legalMin, maxTo);
return wrap;
},
// Says something when the amount typed isn't a legal raise. It never blocks —
// a dealer overruling the arithmetic is right more often than the arithmetic is,
// and the whole tool is built on tapping what the humans actually did.
//
// But an under-minimum raise from a player with chips behind them is the single
// most common thing that stops a home game dead, and until now the bar took it
// without a word: type 13 when the minimum is 18 and it was simply accepted. The
// engine then treats it as a short raise and correctly doesn't reopen the betting,
// which is right for an all-in and wrong for somebody who just misheard the bet.
updateRaiseNote(wrap, minTo, maxTo) {
const note = wrap.querySelector(".raiseNote");
if (!note) return;
const typed = Number(this.raiseDraft);
const current = this.state.currentBet || 0;
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
// Going all-in for less than a full raise is perfectly legal, and worth saying
// out loud because of what follows from it.
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
note.textContent =
`${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
note.className = "raiseNote raiseNoteWarn";
note.hidden = false;
return;
}
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
note.textContent =
`All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
note.className = "raiseNote raiseNoteInfo";
note.hidden = false;
return;
}
if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; }
if (typed > maxTo + Betting.EPSILON_CHIPS) {
note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`;
note.className = "raiseNote raiseNoteInfo";
note.hidden = false;
return;
}
if (typed <= current) {
note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`;
note.className = "raiseNote raiseNoteWarn";
note.hidden = false;
return;
}
note.hidden = true;
},
renderStreetDone() {
const row = document.createElement("div");
row.className = "actionButtons";
+40
View File
@@ -22,6 +22,46 @@ 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 target sizes, touch-action and tap highlights, that fields stay selectable and are large
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
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.
Touch targets are measured by probing outward with `elementFromPoint` rather than by
reading the element box, because the box lies in both directions: a small control can
be padded out with a transparent `::after` and respond well beyond its painted size,
and a checkbox inside a label is hit by tapping the label. The 44px rule is only
enforced where `hover: none` says there's no mouse.
It exists because these checks were being re-typed into a console every time
something changed, and each time they only covered whatever had just been touched.
That is how the standings table shipped with a player-name column zero pixels wide
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
+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();
+499
View File
@@ -0,0 +1,499 @@
// 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) ----
// What a fingertip can actually hit, which is not the same as the element's box.
//
// Two things make the box misleading, and the first version of this check was
// fooled by both. A control can be padded out with a transparent ::after, so it
// responds well beyond its painted size — that's how a 22px dealer puck should
// stay 22px and still take a thumb. And a checkbox wrapped in a label is hit by
// tapping anywhere on the label, so a 24px box inside a 320x44 label is a
// perfectly good target. Measuring the box reported both as faults.
//
// So it probes outwards with elementFromPoint until the point stops landing on the
// control, which is the thing a finger actually experiences.
effectiveTarget(el, limit = 48) {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// The probe walks outward a fixed distance, so it can only ever report
// 2 x limit. Anything already drawn larger than that reads as exactly the cap
// — which had every header button reporting a suspiciously round 80px. Give
// the reach room for the element itself plus the padding being looked for.
limit = Math.max(limit, rect.width / 2 + 24, rect.height / 2 + 24);
// A wrapping or associated label forwards the tap, so it counts as the target.
const label = el.closest("label") || (el.labels && el.labels[0]);
const lands = (x, y) => {
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
const hit = document.elementFromPoint(x, y);
if (!hit) return false;
if (hit === el || el.contains(hit)) return true;
return !!(label && (hit === label || label.contains(hit)));
};
if (!lands(cx, cy)) return null; // centre isn't even hittable; occlusion's problem
const reach = (dx, dy) => {
let far = 0;
for (let d = 1; d <= limit; d++) {
if (!lands(cx + dx * d, cy + dy * d)) break;
far = d;
}
return far;
};
return { width: reach(-1, 0) + reach(1, 0), height: reach(0, -1) + reach(0, 1) };
},
// The 44px guideline is about fingertips, so it only bites where there's no mouse.
// Enforcing it everywhere flagged the header buttons at 42px tall on a desktop,
// which is not a defect — the same buttons are 64px tall on a phone, where it
// matters. Same reasoning as the field sizing: `hover: none` is what actually
// distinguishes a finger from a pointer, not viewport width.
//
// `exempt` is for controls that are deliberately small *and* have a full-size path
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
// the rim of a seat, and growing it to 44px would take that area from the seat
// itself — which a dealer taps far more often. "Give the button" is in the seat
// menu at full size, so the puck is a shortcut, not the only way through.
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
const offenders = [];
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
}
this.visibleControls()
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
const rect = el.getBoundingClientRect();
// Drawn big enough is big enough — no need to probe, and probing wide
// elements is where the cap above used to produce nonsense. Seats sit
// here: they're 76x72 and overlap each other at the rim by design, so
// measuring their reach would report the crowding as a fault.
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
const size = this.effectiveTarget(el);
if (!size) return;
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
offenders.push(
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
});
return { name: `touch targets at least ${minimum}px`, offenders };
},
// ---- 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;
}