diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index a44a751..36c00b3 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -25,7 +25,7 @@
-
Texas Hold'em
+
Pot $0 @@ -104,16 +104,31 @@ Session

Standings

-
-
- Player - In - Won - Net +
+
+
+ Player + In + Won + Net + Cash out +
+
-
-

Once you resolve a pot, everyone's running total for the night shows up here.

+

Everyone who has bought in shows up here, with what they'd be owed if the night ended now.

+ +

Hands

@@ -225,6 +240,7 @@ +
@@ -299,6 +315,68 @@

+
+

A short all-in doesn't reopen the betting

+

+ Somebody shoving in less than a full raise — say $3 more when the last + raise was $8 — does not give the players behind them another go. Anyone + who has already acted owes the difference or folds; they don't get to + raise again because a short stack happened to land between them. +

+

+ A player who hadn't acted yet keeps full rights and may still raise, and + the minimum raise stays measured off the last full raise, not + off the short one. The action bar follows this for you. +

+
+ +
+

Chips nobody called come back

+

+ Bet more than anyone was able to call and the excess is returned to you + before the showdown — it was never contested, so it isn't part of the + pot. The pot breakdown shows it as "uncalled" rather than pretending it's + a side pot you could win. +

+
+ +
+

One big chip, nothing said

+

+ The most common argument at a home table. A player facing a bet who + pushes out a single oversized chip without saying anything has + called — not raised, however big the chip is. To raise with one + chip you have to say "raise" before it lands; say it without an amount + and the raise is the most that chip allows. +

+

+ Facing no bet, the same silent chip is a bet of the whole chip. And a + clear verbal declaration in turn is binding either way — if words and + chips disagree, whichever came first is the action. +

+
+ +
+

A raise has to be a full raise

+

+ At least as much again as the last raise, or the big blind if there + hasn't been one. Anything between a call and that minimum isn't a raise + a player can make — it's a call, unless they're putting their last chips + in. The raise pad says so if you type one, and won't stop you: your + ruling beats the arithmetic. +

+
+ +
+

The odd chip in a split pot

+

+ When a chopped pot won't divide evenly, the odd chip goes to the first + player left of the button, not to whoever the dealer happened to tick + first. Pot payouts here order the winners that way, so the leftover lands + on the right seat every time. +

+
+

Follows the Tournament Directors Association rules, which most rooms and home games take their lead from. diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 8687072..b413bb0 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -582,6 +582,23 @@ button, z-index: 2; opacity: 0.32; transition: opacity 0.15s ease; + /* A dealer puck should look like a puck — small, sitting on the rim of its seat. + But tapping it hands the button to that seat and re-posts the blinds, and 22px + is half the 44px a fingertip needs. The hit area grows without the chip doing + so: the visual stays 22px and an invisible pad around it catches the tap. + Deliberately not centred on the chip — it's pushed down and left, away from the + neighbouring seat the chip juts towards, so the bigger target can't start + stealing taps meant for the seat next door. */ + isolation: isolate; +} + +.dealerChip::after { + content: ""; + position: absolute; + top: -9px; + right: -7px; + bottom: -15px; + left: -14px; } .dealerChip:focus-visible { @@ -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; + grid-template-columns: minmax(4rem, 1fr) auto auto; gap: 0.5rem; align-items: baseline; padding: 0.3rem 0.1rem; @@ -1478,7 +1524,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 { @@ -1912,12 +2029,50 @@ body.hasActionBar #holdemSidebar { color: #f0d9a8; } -/* Whose turn it is, shown on the table itself as well as in the bar. */ +/* Whose turn it is, shown on the table itself as well as in the bar. + + Dealers say the biggest single time sink at a home table isn't how fast the cards + come out — it's players not noticing the action is on them. So this is the one + state that has to be unmistakable from across a table at a glance, and it was + distinguished only by a rim colour: a gold border and a 2px ring where a live seat + has a green border and a 1px one. Same fill, same size, nothing moving. + + It now reads as lit rather than merely outlined: a solid ring at full strength, a + warm glow that separates it from the felt, and a lift of its own. The seat's + `transform` positions it around the table, so the lift is box-shadow rather than + translate — setting transform here would throw the seat off its spot. */ .seat.seatActing { - border-color: #d8b46a; + border-color: #f0cd85; + background: linear-gradient(160deg, #3a3222, #241f16); box-shadow: - 0 0 0 2px rgba(216, 180, 106, 0.45), - 0 4px 16px rgba(0, 0, 0, 0.55); + 0 0 0 3px rgba(216, 180, 106, 0.9), + 0 0 18px 2px rgba(216, 180, 106, 0.35), + 0 6px 20px rgba(0, 0, 0, 0.6); + z-index: 4; +} + +/* A slow breath, so a dealer glancing up finds it without hunting. Deliberately + gentle and deliberately optional — the ring above carries the whole job on its own + for anyone who'd rather nothing moved. */ +@media (prefers-reduced-motion: no-preference) { + .seat.seatActing { + animation: seatActingPulse 2.4s ease-in-out infinite; + } + + @keyframes seatActingPulse { + 0%, 100% { + box-shadow: + 0 0 0 3px rgba(216, 180, 106, 0.9), + 0 0 18px 2px rgba(216, 180, 106, 0.35), + 0 6px 20px rgba(0, 0, 0, 0.6); + } + 50% { + box-shadow: + 0 0 0 3px rgba(216, 180, 106, 0.65), + 0 0 26px 5px rgba(216, 180, 106, 0.5), + 0 6px 20px rgba(0, 0, 0, 0.6); + } + } } .seatPuckStack { @@ -2055,6 +2210,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) { @@ -2123,7 +2282,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 +2301,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 +2901,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; + } +} diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css index 2cc5a6b..582aa85 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css +++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css @@ -516,6 +516,15 @@ button, text-align: center; } +/* This footnote names a control the dealer is being told to reach for, so the name + itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey + the surrounding sentence is deliberately set in. Emphasised text carrying the + instruction gets its own colour rather than the aside's. */ +.splitFootnote strong { + color: #a8a8a8; + font-weight: 600; +} + .splitFootnote a { color: #a9782f; } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 6029bcc..044c895 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -319,12 +319,26 @@ const ActionBar = { // Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a // button, and only reaches for digits when the table does something odd. renderRaisePad() { + // A toast is transient news about the action just taken; the raise pad is the + // dealer deciding the next one. On a short screen they compete for the same + // strip: the pad makes the bar 217px tall on a 640px phone, and the toast — + // which floats just above the bar — gets pushed up over the table, covering + // seats outright. Rather than squeeze both in, the older one gets out of the + // way. Nothing is lost: the toast's Undo is also the bar's own undo control. + if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss(); + const index = this.state.actingSeatIndex; const player = this.state.seats[index]; const stack = Betting.stackOf(this.state, player); const maxTo = Betting.round(Betting.committed(player) + stack); const pot = Betting.potSoFar(this.state); - const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo); + // Two different minimums, and conflating them was a bug. `minTo` is clamped to + // what this player can actually put in, which is what the Min button should + // offer. `legalMin` is what the rules require — for a short stack the two are + // different, and it's the rules one the warnings have to be measured against, + // or a player whose whole stack is under a full raise never gets told that. + const legalMin = Betting.minRaiseTo(this.state); + const minTo = Math.min(legalMin, maxTo); const wrap = document.createElement("div"); wrap.className = "raisePad"; @@ -360,6 +374,7 @@ const ActionBar = { input.setAttribute("aria-label", "Raise to amount"); input.addEventListener("input", () => { this.raiseDraft = input.value.replace(/[^0-9.]/g, ""); + this.updateRaiseNote(wrap, legalMin, maxTo); }); entry.appendChild(input); @@ -376,9 +391,73 @@ const ActionBar = { })); wrap.appendChild(entry); + + const note = document.createElement("p"); + note.className = "raiseNote"; + note.hidden = true; + wrap.appendChild(note); + this.updateRaiseNote(wrap, legalMin, maxTo); + return wrap; }, + // Says something when the amount typed isn't a legal raise. It never blocks — + // a dealer overruling the arithmetic is right more often than the arithmetic is, + // and the whole tool is built on tapping what the humans actually did. + // + // But an under-minimum raise from a player with chips behind them is the single + // most common thing that stops a home game dead, and until now the bar took it + // without a word: type 13 when the minimum is 18 and it was simply accepted. The + // engine then treats it as a short raise and correctly doesn't reopen the betting, + // which is right for an all-in and wrong for somebody who just misheard the bet. + updateRaiseNote(wrap, minTo, maxTo) { + const note = wrap.querySelector(".raiseNote"); + if (!note) return; + + const typed = Number(this.raiseDraft); + const current = this.state.currentBet || 0; + + if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; } + + // Going all-in for less than a full raise is perfectly legal, and worth saying + // out loud because of what follows from it. + const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS; + + if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) { + note.textContent = + `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`; + note.className = "raiseNote raiseNoteWarn"; + note.hidden = false; + return; + } + + if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) { + note.textContent = + `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`; + note.className = "raiseNote raiseNoteInfo"; + note.hidden = false; + return; + } + + if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; } + + if (typed > maxTo + Betting.EPSILON_CHIPS) { + note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`; + note.className = "raiseNote raiseNoteInfo"; + note.hidden = false; + return; + } + + if (typed <= current) { + note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`; + note.className = "raiseNote raiseNoteWarn"; + note.hidden = false; + return; + } + + note.hidden = true; + }, + renderStreetDone() { const row = document.createElement("div"); row.className = "actionButtons"; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 803d739..29f293f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -153,14 +153,33 @@ const Betting = { const delta = this.round(target - this.committed(player)); const moved = this.putIn(state, index, delta); const newBet = this.committed(player); + const previousBet = state.currentBet || 0; + + if (newBet > previousBet) { + const increase = this.round(newBet - previousBet); + // What a full raise would have had to be: at least as much again as the + // last one, or the big blind if there hasn't been a raise yet. + const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0); + const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS; - if (newBet > (state.currentBet || 0)) { - state.lastRaiseSize = this.round(newBet - (state.currentBet || 0)); state.currentBet = newBet; - // A raise reopens the action: everyone else owes a response again. - state.seats.forEach((p, i) => { - if (i !== index && p.inHand && !p.folded) p.hasActed = false; - }); + + if (isFullRaise) { + state.lastRaiseSize = increase; + // A real raise reopens the action: everyone else owes a response again. + state.seats.forEach((p, i) => { + if (i !== index && p.inHand && !p.folded) p.hasActed = false; + }); + } + // Otherwise this is a short all-in — somebody with less than a full raise + // left shoving it in. The most misread rule in poker: it does *not* reopen + // the betting to anyone who has already acted. They owe the difference or + // they fold; they don't get to raise again because a short stack happened + // to be between them. Players yet to act keep their full rights, which + // they do here because their hasActed is already false. + // + // lastRaiseSize deliberately isn't lowered either, or the next legal raise + // would be computed off the short amount and come out too small. } player.hasActed = true; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 56c2494..31be248 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -318,15 +318,34 @@ document.addEventListener("DOMContentLoaded", () => { Render.renderAll(); }); - document.getElementById("nextHandBtn").addEventListener("click", () => { + document.getElementById("nextHandBtn").addEventListener("click", async () => { + // Moving on wipes every seat's contribution, so an unresolved pot is simply + // destroyed — measured at a four-handed table, pressing this with $8 in the + // middle took the table from 800 chips to 792, and the toast said nothing + // about it. Chips only ever leave this tool because a dealer meant them to. + const unresolved = HoldemState.unresolvedContributions(state); + + if (unresolved > Betting.EPSILON_CHIPS) { + const ok = await Dialog.confirm({ + title: `Throw away ${Money.format(unresolved)}?`, + message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.", + confirmLabel: "Throw it away", + danger: true, + }); + if (!ok) return; + } + // startNextHand moves the button on by one; say where it landed so the // dealer can confirm the rotation without hunting for the badge. const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1); const where = state.dedicatedDealer ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}` : `button on seat ${nextDealer + 1}`; + const discarded = unresolved > Betting.EPSILON_CHIPS + ? ` ${Money.format(unresolved)} taken off the table.` + : ""; - runUndoable(`Next hand — ${where}. Blinds posted.`, () => { + runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => { HoldemState.startNextHand(state); Betting.beginHand(state); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index f9d23fe..c4c47f9 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -272,11 +272,25 @@ const PotWizard = { }); actions.appendChild(applyBtn); - const closeBtn = document.createElement("button"); - closeBtn.className = "holdemBtn holdemBtnGhost"; - closeBtn.textContent = "Close"; - closeBtn.addEventListener("click", () => this.close()); - actions.appendChild(closeBtn); + // This button cancels — it does not pay anybody. Under a heading reading + // "Final Payouts" with an amount beside a name, "Close" reads like dismissing + // something already done, and a dealer who taps it walks away believing the + // pot has been pushed. It hasn't: the chips are still in the middle and the + // hand isn't logged. So it says what it does. + const cancelBtn = document.createElement("button"); + cancelBtn.className = "holdemBtn holdemBtnGhost"; + cancelBtn.textContent = "Cancel — don't pay yet"; + cancelBtn.addEventListener("click", () => this.close()); + actions.appendChild(cancelBtn); + + if (this.totals.size > 0) { + const note = document.createElement("p"); + note.className = "fieldHint potWizardPendingNote"; + note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand."; + wrap.appendChild(actions); + wrap.appendChild(note); + return wrap; + } wrap.appendChild(actions); return wrap; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index eebe5b1..7956fd1 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -701,8 +701,46 @@ const Render = { netEl.classList.toggle("netUp", row.net > 0); netEl.classList.toggle("netDown", row.net < 0); + // What they'd actually be handed if the night ended now: chips in front of + // them less the cash they gave the banker. This was computed and written to + // the CSV all along, but never shown to the person doing the paying out. + const cashEl = node.querySelector(".standingsCashOut"); + cashEl.textContent = Money.formatSigned(row.cashPosition); + cashEl.classList.toggle("netUp", row.cashPosition > 0); + cashEl.classList.toggle("netDown", row.cashPosition < 0); + list.appendChild(node); }); + + this.renderBankReconciliation(); + }, + + // Does the cash box cover the chips on the table? Nothing asked this before, and + // it's the question that decides whether the last two players can be paid. + renderBankReconciliation() { + const host = document.getElementById("bankReconcile"); + if (!host) return; + + const bank = HoldemState.bankReconciliation(this.state); + // Nothing to reconcile before anybody has handed over money or taken a chip. + host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0; + if (host.hidden) return; + + document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn); + document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay); + + const verdict = document.getElementById("bankReconcileVerdict"); + if (bank.balanced) { + verdict.textContent = "Balanced — the cash covers every chip on the table."; + } else if (bank.difference > 0) { + verdict.textContent = + `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`; + } else { + verdict.textContent = + `${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`; + } + verdict.classList.toggle("bankBalanced", bank.balanced); + verdict.classList.toggle("bankOff", !bank.balanced); }, // ---- Hand counter + history ---- diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 9eac9b8..1cd4163 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -167,6 +167,38 @@ const HoldemState = { return !player.inHand; }, + // What starting the next hand would take off the table for good: exactly the + // fields startNextHand clears. + // + // Deliberately not totalPot(). The table-wide base pot is a standing ante — a + // stand-in for "everyone puts the same in every hand", used by a dealer who + // isn't tracking stacks — so it re-applies to the next hand rather than being + // lost with this one. Counting it would mean warning about money that isn't + // going anywhere, on every single hand, for exactly the dealers who chose the + // simplest way to run the table. + unresolvedContributions(state) { + const total = state.seats.reduce((sum, player) => { + const override = player.amountOverride; + let banked = 0; + + if (override !== null && override !== undefined && String(override).trim() !== "") { + const parsed = Number(override); + if (!Number.isNaN(parsed)) banked = Math.max(0, parsed); + } + + if (banked === 0) { + banked = (state.chipLegend || []).reduce((chips, chip) => { + const count = Number(player.chipCounts[chip.id]) || 0; + return chips + count * Number(chip.value || 0); + }, 0); + } + + return sum + banked + Math.max(0, Number(player.committed) || 0); + }, 0); + + return Money.round(total); + }, + totalPot(state) { return state.seats.reduce( (sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount), @@ -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) { diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 34582a9..10ad8f7 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -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 diff --git a/tests/holdem/bankAudit.test.js b/tests/holdem/bankAudit.test.js new file mode 100644 index 0000000..eb2f2b2 --- /dev/null +++ b/tests/holdem/bankAudit.test.js @@ -0,0 +1,169 @@ +// The banker's job, which is a different job from the dealer's: every chip on the +// table is a promise to pay cash for it later, and somebody has to be able to check +// that the promise can be kept before the last two players try to cash out. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, HoldemState, Money, makeTable } = require("./load"); + +const seat = (state, index, name, cash, chips) => { + const p = state.seats[index]; + p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips; + return p; +}; + +describe("everyone with money in the game is accounted for", () => { + test("a player who has bought in but played no logged hand still appears", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 }); + seat(state, 0, "Ann", 100, 100); + seat(state, 1, "Ben", 100, 100); + seat(state, 2, "Cal", 200, 200); // just sat down + + state.seats[0].amountOverride = 10; + state.seats[1].amountOverride = 10; + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]); + state.seats[0].amountOverride = null; + state.seats[1].amountOverride = null; + + const names = HoldemState.sessionStandings(state).map(r => r.name); + assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`); + }); + + test("their row reads as no hands played, not as a loss", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 }); + seat(state, 0, "Ann", 100, 100); + seat(state, 1, "Ben", 200, 200); + + const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben"); + assert.equal(cal.in, 0); + assert.equal(cal.won, 0); + assert.equal(cal.net, 0); + assert.equal(cal.boughtIn, 200); + assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them"); + }); + + test("an empty chair is not dragged into the settle-up", () => { + const state = makeTable({ seatCount: 9, seated: [0], stack: 0 }); + seat(state, 0, "Ann", 100, 100); + const rows = HoldemState.sessionStandings(state); + assert.equal(rows.length, 1); + }); + + test("somebody who has cashed out completely still shows what they took", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 }); + seat(state, 0, "Ann", 100, 0); // handed every chip back + seat(state, 1, "Ben", 100, 200); + const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann"); + assert.equal(ann.cashPosition, -100, "down the whole buy-in"); + }); +}); + +describe("does the cash box cover the chips on the table", () => { + test("a straight game balances", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 }); + seat(state, 0, "Ann", 100, 60); + seat(state, 1, "Ben", 100, 140); + seat(state, 2, "Cal", 100, 100); + + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.cashIn, 300); + assert.equal(bank.chipsInPlay, 300); + assert.equal(bank.difference, 0); + assert.equal(bank.balanced, true); + }); + + test("chips outrunning the cash box is caught before cash-out", () => { + // The 2am failure: the last players out can't be paid. + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 }); + seat(state, 0, "Ann", 100, 100); + seat(state, 1, "Ben", 100, 100); + seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down + + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.cashIn, 300); + assert.equal(bank.chipsInPlay, 460); + assert.equal(bank.difference, 160); + assert.equal(bank.balanced, false); + }); + + test("cash taken for chips never handed over is caught too", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 }); + seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips + seat(state, 1, "Ben", 100, 100); + + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.difference, -100); + assert.equal(bank.balanced, false); + }); + + test("chips in the middle still count as chips in play", () => { + // Money in the pot is somebody's; it just hasn't been won yet. Leaving it out + // would report a shortfall every time a hand was open — which is nearly always. + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); + state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; }); + + const before = HoldemState.bankReconciliation(state); + assert.equal(before.balanced, true); + + Betting.beginHand(state); // blinds go in + Betting.raiseTo(state, 0, 25); // and a raise + Betting.sweepToHandTotals(state); // banked into the pot + + const during = HoldemState.bankReconciliation(state); + assert.equal(during.balanced, true, "an open pot is not a discrepancy"); + assert.equal(during.chipsInPlay, before.chipsInPlay); + }); + + test("a whole session of play never knocks the books out", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; }); + + for (let hand = 0; hand < 6; hand++) { + Betting.beginHand(state); + Betting.liveSeats(state).forEach(s => { + if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); + }); + Betting.sweepToHandTotals(state); + + const pot = HoldemState.totalPot(state); + const winner = Betting.liveSeats(state)[0]; + const payouts = [{ id: winner.player.id, label: "w", amount: pot }]; + HoldemState.recordHand(state, payouts); + Betting.creditWinnings(state, payouts); + HoldemState.startNextHand(state); + + assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`); + } + }); + + test("a rebuy keeps the books straight, a chip grab doesn't", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 }); + seat(state, 0, "Ann", 100, 100); + seat(state, 1, "Ben", 100, 100); + + // A proper rebuy: cash to the banker, chips to the player. + state.seats[0].buyIns += 100; + state.seats[0].stack += 100; + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + + // Chips taken without paying. + state.seats[1].stack += 50; + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.balanced, false); + assert.equal(bank.difference, 50); + }); + + test("nothing to reconcile before anybody has bought in", () => { + const state = makeTable({ seatCount: 6, seated: [], stack: 0 }); + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.cashIn, 0); + assert.equal(bank.chipsInPlay, 0); + assert.equal(bank.balanced, true); + }); + + test("float dust is not a discrepancy", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 }); + seat(state, 0, "Ann", 0.1 + 0.2, 0.3); + seat(state, 1, "Ben", 10, 10); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); +}); diff --git a/tests/holdem/potSafety.test.js b/tests/holdem/potSafety.test.js new file mode 100644 index 0000000..b687839 --- /dev/null +++ b/tests/holdem/potSafety.test.js @@ -0,0 +1,103 @@ +// Chips only ever leave this tool because a dealer meant them to. Starting the next +// hand clears every seat's contribution, so an unresolved pot is simply destroyed — +// the UI now asks first, and the number it quotes has to be exactly what goes. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load"); + +// The same measure the confirmation uses. +const unresolvedPot = state => HoldemState.unresolvedContributions(state); + +describe("what starting the next hand destroys", () => { + test("the quoted amount is exactly what leaves the table", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + const before = chipsOnTable(state); + const quoted = unresolvedPot(state); + assert.ok(quoted > 0, "there is a pot to warn about"); + + HoldemState.startNextHand(state); + + assert.equal( + Money.toCents(chipsOnTable(state)), + Money.toCents(before - quoted), + "the dealer must be told the real number, not an approximation" + ); + }); + + test("chips still in front of players on the current street are counted too", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); // blinds are `committed`, not yet banked + Betting.raiseTo(state, 0, 25); // and so is a raise mid-street + + const before = chipsOnTable(state); + const quoted = unresolvedPot(state); + HoldemState.startNextHand(state); + + assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted)); + assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`); + }); + + test("a hand that was paid out properly leaves nothing to warn about", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + const pot = HoldemState.totalPot(state); + const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }]; + HoldemState.recordHand(state, payouts); + Betting.creditWinnings(state, payouts); + HoldemState.startNextHand(state); + + assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand"); + }); + + test("settling then dealing on conserves every chip", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + const start = chipsOnTable(state); + + for (let hand = 0; hand < 5; hand++) { + Betting.beginHand(state); + const live = Betting.liveSeats(state); + live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); }); + Betting.sweepToHandTotals(state); + + const pot = HoldemState.totalPot(state); + const winner = Betting.liveSeats(state)[0]; + const payouts = [{ id: winner.player.id, label: "w", amount: pot }]; + HoldemState.recordHand(state, payouts); + Betting.creditWinnings(state, payouts); + HoldemState.startNextHand(state); + + assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`); + } + }); + + test("an empty table has no phantom pot to warn about", () => { + const state = makeTable({ seatCount: 6, seated: [] }); + assert.equal(unresolvedPot(state), 0); + }); + + test("a standing ante is never warned about, because it isn't lost", () => { + // The table default is a stand-in for "everyone puts the same in every hand", + // used by a dealer not tracking stacks at all. It re-applies to the next hand + // rather than going anywhere, so counting it would nag this dealer before + // every single hand — which is why the warning measures the fields + // startNextHand actually clears rather than the pot badge. + const state = makeTable({ seatCount: 9, seated: [0, 1] }); + state.basePotAmount = 25; + state.seats.forEach(p => { p.amountOverride = null; }); + + // The base pot carries into the next hand, so nothing is being thrown away + // and the dealer must not be asked to confirm anything. + assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot"); + assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning"); + + HoldemState.startNextHand(state); + assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too"); + }); +}); diff --git a/tests/holdem/reopeningBetting.test.js b/tests/holdem/reopeningBetting.test.js new file mode 100644 index 0000000..7ee548d --- /dev/null +++ b/tests/holdem/reopeningBetting.test.js @@ -0,0 +1,143 @@ +// When does a bet reopen the action? The most misread rule in poker, and the one a +// home game argues about longest: a player shoving in less than a full raise does not +// give everybody behind them another go. +// +// TDA Rule 44 — an all-in wager of less than a full raise does not reopen the betting +// to a player who has already acted and is not facing a full raise. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, makeTable, chipsOnTable } = require("./load"); + +const actedFlags = state => state.seats + .map((p, i) => ({ seat: i, acted: p.hasActed, live: p.inHand && !p.folded })) + .filter(s => s.live); + +describe("a short all-in does not reopen the betting", () => { + // Blinds 1/2. Seat 4 raises to 10 (a full raise of 8). Seat 1 calls. + // Seat 2 is all-in for 13 — three more, where a full raise needs eight. + const shortAllInTable = () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[1].stack = 13; + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); + Betting.call(state, 0); + return state; + }; + + test("players who already acted are not asked to act again", () => { + const state = shortAllInTable(); + assert.equal(state.seats[0].hasActed, true, "seat 1 has called"); + assert.equal(state.seats[3].hasActed, true, "seat 4 has raised"); + + Betting.allIn(state, 1); + + assert.equal(state.seats[0].hasActed, true, "seat 1 must not be reopened"); + assert.equal(state.seats[3].hasActed, true, "seat 4 must not be reopened"); + }); + + test("but they do still owe the difference", () => { + const state = shortAllInTable(); + Betting.allIn(state, 1); + + assert.equal(Betting.toCall(state, state.seats[0]), 3, "seat 1 owes the extra 3"); + assert.equal(Betting.isStreetComplete(state), false, "so the street isn't over"); + }); + + test("a player who had not acted yet keeps full rights", () => { + const state = shortAllInTable(); + assert.equal(state.seats[2].hasActed, false, "seat 3 hasn't acted"); + Betting.allIn(state, 1); + assert.equal(state.seats[2].hasActed, false, "and still hasn't — they may still raise"); + }); + + test("the minimum raise is not shrunk by the short all-in", () => { + const state = shortAllInTable(); + assert.equal(state.lastRaiseSize, 8); + + Betting.allIn(state, 1); + + assert.equal(state.lastRaiseSize, 8, "still the last *full* raise"); + assert.equal(Betting.minRaiseTo(state), 21, "13 to call plus a full raise of 8"); + }); + + test("calling the short all-in ends the street", () => { + const state = shortAllInTable(); + Betting.allIn(state, 1); + Betting.call(state, 2); // seat 3, who had full rights, just calls + Betting.call(state, 3); + Betting.call(state, 0); + assert.equal(Betting.isStreetComplete(state), true); + }); + + test("no chips are created or lost by any of it", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[1].stack = 13; + const start = chipsOnTable(state); + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); + Betting.call(state, 0); + Betting.allIn(state, 1); + Betting.call(state, 2); + Betting.call(state, 3); + Betting.call(state, 0); + assert.equal(chipsOnTable(state), start); + }); +}); + +describe("a full raise still reopens the betting", () => { + test("an all-in that is a full raise or more reopens everyone", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[1].stack = 40; + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); + Betting.call(state, 0); + + Betting.allIn(state, 1); // to 40: an increase of 30, well over the 8 needed + + assert.equal(state.seats[0].hasActed, false, "seat 1 gets another go"); + assert.equal(state.seats[3].hasActed, false, "and so does seat 4"); + assert.equal(state.lastRaiseSize, 30); + assert.equal(Betting.minRaiseTo(state), 70); + }); + + test("an ordinary raise reopens everyone", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); + Betting.call(state, 0); + assert.equal(state.seats[3].hasActed, true); + + Betting.raiseTo(state, 1, 10); + + assert.equal(state.seats[3].hasActed, false); + assert.equal(state.seats[0].hasActed, false); + }); + + test("a raise exactly the size of the last one counts as full", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); // raise of 8 + Betting.call(state, 0); + + Betting.raiseTo(state, 1, 18); // exactly 8 more — a legal minimum raise + + assert.equal(state.seats[0].hasActed, false, "a minimum raise still reopens"); + assert.equal(state.lastRaiseSize, 8); + }); + + test("the first raise of a street is measured against the big blind", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); + Betting.call(state, 0); + Betting.call(state, 1); + Betting.check(state, 2); + Betting.closeStreet(state); // to the flop, no bet yet + + const first = Betting.nextToAct(state, state.dealerSeatIndex); + Betting.raiseTo(state, first, 2); // a bet of exactly one big blind + + const others = state.seats.filter((p, i) => p.inHand && !p.folded && i !== first); + assert.ok(others.every(p => p.hasActed === false), "a full-sized opening bet reopens everyone"); + }); +}); diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js new file mode 100644 index 0000000..33ba18f --- /dev/null +++ b/tests/holdem/runUiAudit.js @@ -0,0 +1,134 @@ +// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about. +// +// node tests/holdem/runUiAudit.js [baseUrl] +// +// Needs Playwright available (`npx playwright install chromium` once). It is +// deliberately NOT wired into the unit-test suite or CI, because that suite is +// dependency-free and runs in a second — this one needs a browser and a running +// site. Run it after any change that adds or resizes a control. +// +// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools, +// and call LayoutAudit.runAll(). Same checks, one viewport. +const path = require("path"); + +const BASE = process.argv[2] || "http://localhost:5299"; +const AUDIT = path.join(__dirname, "uiAudit.js"); + +// Portrait phones, tablets, desktops, then the same phones on their side — landscape +// is where this layout has broken most often, because the table is sized from width +// and the action bar is fixed to a very short viewport. +const VIEWPORTS = [ + [360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"], + [412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"], + [768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"], + [1280, 900, "desktop"], [1600, 1000, "wide desktop"], + [640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"], +]; + +const OVERLAYS = [ + ["rulings", () => document.getElementById("openRulingsBtn").click()], + ["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)], + ["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }], + ["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})], + ["pot wizard", () => document.getElementById("resolvePotBtn").click()], +]; + +// A table part-way through a real night: long names, uneven stacks, a hand log. +function seedTable() { + const state = HoldemState.createDefault(); + HoldemState.resizeSeats(state, 9); + [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]] + .forEach(([name, cash], i) => { + state.seats[i].name = name; + state.seats[i].inHand = true; + state.seats[i].buyIns = cash; + state.seats[i].stack = cash; + }); + state.smallBlind = 5; + state.bigBlind = 10; + for (let hand = 1; hand <= 4; hand++) { + state.handHistory.push({ + handNumber: hand, timestamp: Date.now(), pot: 120, + payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }], + ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })), + }); + } + state.handNumber = 5; + HoldemState.save(state); +} + +async function main() { + let chromium; + try { + ({ chromium } = require("playwright")); + } catch { + console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium"); + console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll()."); + process.exit(2); + } + + const browser = await chromium.launch(); + const page = await browser.newPage(); + const failures = []; + const warnings = []; + let checks = 0; + + for (const [width, height, label] of VIEWPORTS) { + await page.setViewportSize({ width, height }); + + await page.goto(`${BASE}/Casino/TexasHoldem`); + await page.evaluate(seedTable); + await page.goto(`${BASE}/Casino/TexasHoldem`); + await page.click("#nextHandBtn"); + await page.waitForTimeout(300); + await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; })); + await page.addScriptTag({ path: AUDIT }); + + const table = await page.evaluate(() => LayoutAudit.runAll()); + checks++; + if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) }); + table.warnings.forEach(w => warnings.push(`${label}: ${w}`)); + + for (const [name, open] of OVERLAYS) { + await page.evaluate(open); + await page.waitForTimeout(180); + checks++; + const result = await page.evaluate(() => { + const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden); + if (!overlay) return { offenders: [] }; + return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`); + }); + await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; })); + if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders }); + } + } + + for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) { + await page.setViewportSize({ width, height }); + await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`); + await page.evaluate(() => { + const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent)); + if (button) button.click(); + }); + await page.addScriptTag({ path: AUDIT }); + checks++; + const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" })); + if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) }); + } + + await browser.close(); + + console.log(`${checks} audits across ${VIEWPORTS.length} viewports`); + warnings.forEach(w => console.log(` note: ${w}`)); + if (failures.length === 0) { + console.log("clean"); + return; + } + failures.forEach(f => { + console.log(`\nFAIL ${f.where}`); + f.offenders.forEach(o => console.log(` ${o}`)); + }); + process.exit(1); +} + +main(); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js new file mode 100644 index 0000000..98e91e3 --- /dev/null +++ b/tests/holdem/uiAudit.js @@ -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; +}