Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 4c1ac222a8 Give Enter and Space back to the control that has focus
Found while checking the keyboard could still reach the dealer button: it could
reach it and then couldn't press it.

Enter and Space were bound to "advance" for the whole document, with an exception
only for text fields. Those two keys are how a keyboard presses the control it is
sitting on, so every button on the page lost them -- and not to nothing. Measured:

  Tab to Resolve Pot, press Enter   ->  the table dealt the next hand
  Tab to Resolve Pot, press Space   ->  Resolve Pot, correctly
  Tab to a dealer button, Enter     ->  nothing at all

The inconsistency is the shortcut handler's return value. It calls preventDefault
only when it handled the key, so when "advance" applied it ran instead of the button,
and when it didn't apply the browser's own activation got through. A control that
does something else every other press is worse than one that does nothing.

Enter and Space now go to a focused button, [role=button], link or summary, and the
shortcut only claims them when nothing pressable has focus. The letters stay live
with a button focused -- a button does nothing with "f", and a dealer whose hand is
on the keys shouldn't lose them to a stray focus ring.

Space still advances the street: dealt, f, f, c, c, Space -> flop. The action bar
rebuilds itself after every press, so focus falls back to the body and the dealer's
Space is never quietly aimed at the Fold they just clicked.

11 tests (163 total), and they fail on the old handler -- 4 of them, exactly the
Enter/Space-on-a-control cases.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:15 -06:00
jheapsandClaude Opus 5 784315f2ea Put one dealer button on the table
A poker table has one dealer button. This one drew nine.

Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity
that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all
eight showing the letter D, one of them the button. Finding it meant comparing nine
identical shapes for the one that's gold, when it should be the only thing of its
kind on the felt.

The ghosts also sat on their neighbours. Every remaining "something important is
covered" offender on a crowded table was a live seat covered by the seat-next-door's
ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were
all the same thing.

And a screen reader heard nine buttons named "D".

So a seat without the button doesn't draw one. Hovering a seat fades its slot in --
a preview of where the button would go, one click as before -- and on touch, where
there is no hover, "Give the button" has been in the seat's long-press menu all
along. Keyboard reaches it by focus, where the slot appears too.

pointer-events matters as much as the opacity: an invisible puck that still took
taps would be worse than a visible one.

  visible pucks    9 -> 1 at every size
  layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2
  clean viewports  4 of 9 -> 6 of 9
  labels           nine "D" -> "Ann has the dealer button" and
                              "Give the dealer button to Ben"

The audit now checks it, because this is a rule about the table rather than about
CSS: one puck drawn, and no two announcing themselves the same way. Verified against
the old behaviour put back -- it reports 9 and 9.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:00 -06:00
jheapsandClaude Opus 5 0c6cd55951 Let the landscape table use the width it has
Last round I left a residual and said it wanted its own fix: on a phone held
sideways, seats overlapped so badly that a seat's centre belonged to the seat next
door. Here's the cause.

The landscape rule capped the felt's width by its height to hold the portrait
aspect ratio. At 640x360 that produced a table 135px wide inside a 601px column --
22% of the screen -- with 366px of rim for nine seats needing 662px. Adjacent seat
centres ended up 13px apart for seats rendering 74px wide.

Landscape has width and no height, so the table now takes the width it has and is
only as tall as the room left over. A wide ellipse, which is what a real poker
table is anyway.

  640x360   felt 135x97  ->  601x121   rim 366 -> 1263   centres 13px -> 33px
  844x390   felt 176x127 ->  765x151   rim 480 -> 1605   centres 37px -> 59px
  915x412   felt 207x149 ->  492x173   rim 563 -> 1106   centres 44px -> 55px

Seat ownership went from 8 seats wrong at 640x360 and 2 at 844x390 to zero at
844x390, zero at 915x412, and two at the very smallest. Nine of eleven sizes clean,
portrait and desktop untouched.

Two things I tried and threw away

Spacing seats by arc length instead of by angle. It sounds obviously right -- even
distance around the rim rather than even angle -- and it made things worse: 640x360
went from 2 wrong to 3, closest centres 33px to 21px, and even the desktop table
dropped from 180px to 155px. Even arc spacing packs seats through the tight turns
at the ends of an ellipse, where a fixed distance along the rim covers much less
straight-line distance. Equal angles happen to separate them better. Reverted.

Shrinking seats on the tightest landscape phones. Took the rendered seat from 74px
to 64px and moved closest centres not at all -- still 33px -- while the covered
count at 844x390 went from 3 to 4. A change that measures as nothing but costs CSS
and legibility isn't worth keeping. Reverted.

So this is one CSS rule, which is all the measurements supported.

What's left

640x360 and 667x375 still have two seats whose centre lands on a neighbour. Nine
seats on a ring 121px tall is a genuine physical constraint rather than a layout
mistake, and both remaining fixes I tried made it worse rather than better. It is
reported by the audit rather than hidden, and a dealer on a 360px-tall screen has
the seat menu and the action bar for everything that matters.

152 tests passing; contrast clean at every size; dealing still works in landscape.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:39:45 -06:00
jheapsandClaude Opus 5 e01d8e7c49 Tapping a seat gives you that seat
Went to audit the seat editor and couldn't open it: Playwright refused to click the
middle of seat 1 because seat 9's dealer chip was sitting there. On a nine-seat
table, tapping the centre of one player handed the button to another.

I shipped that. The enlarged chip hit area grew outward from a chip already hung
over its seat's corner at -10px/-10px -- and on an oval, a seat's corner is the
seat next to it. Measured on the nine-seat table:

  seat 1  10 of 49 sampled points taken by seat 9's chip, including its centre
  seat 4   9 of 49 taken by seat 5's chip
  seat 5   3 of 49 taken by seat 6's chip

I claimed at the time that no seat tap was stolen, and I had checked -- with an
audit that exempted anything inside a `.seat` as harmless crowding. The thief was
inside a seat. The check walked straight past it.

Two fixes, and the second matters more

The chip sits inside its seat now rather than on the rim, and its hit area grows
inward over its own seat, which is a big target that can spare a corner. Padding
inward alone wasn't enough -- I tried it and the numbers didn't move, because the
chip's own box was the problem, not just the pad. Isolating that took disabling the
pad and then the overhang separately: pad off gave 2 seats affected, overhang off
gave zero.

And the audit now asks whether a seat's centre belongs to that seat, as its own
check. The occlusion check couldn't answer it: it was built for overlays on top of
the table, and this was two pieces of the table fighting over a tap. It exempts a
neighbour's body -- seats do overlap by design -- but not a neighbour's controls,
which was the exact hole.

Seven of eight sizes now have no seat whose centre goes elsewhere, and all four
seats I tap open their own editor.

What's left, stated rather than buried

At 360x640 and 375x667 one seat still loses a single sample point of its rim to a
neighbour's chip. Centres are correct; this is the rim of a crowded oval.

At 844x390 two seats have centres belonging to the seat next door -- and those are
seat bodies, not chips. Nine seats on a 163px-tall landscape table overlap almost
completely. That's the landscape felt sizing, it predates all of this, and it wants
fixing on its own terms rather than being folded in here.

Both are now reported by the audit instead of hidden by it, which is the part I
actually trust.

152 tests still passing; contrast clean at every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:24:14 -06:00
jheapsandClaude Opus 5 c77161c3e9 Stop the last two things the table did behind the dealer's back
Your bug report was about a category, not one button, so I went looking for the
rest of the guessing rather than picking a new topic. Two left, both about the
button -- and one of them was in the fix I'd just shipped.

The deal preview lied

The prompt added last time promises who will post. With the button parked on an
empty chair it promised the wrong pair: it read the button where it currently sat,
while dealing relocates the button onto a player first and only then works out the
blinds.

  three players in seats 2-4, button parked on empty seat 1
    preview said:  s2 posts $1, s3 posts $2
    dealing gave:  s3 posts $1, s4 posts $2

Exactly the fault you reported -- the display and the thing it describes computed
from different moments -- inside the change meant to remove it. buttonSeatForDeal
answers where the button will be without moving it, so the preview asks the same
question the deal will answer. And the move is now stated before it happens:
"Button moves to Ben - seat 1 is empty."

Moving the button after the deal moved the blinds silently

Once the blinds are posted, moving the button takes them back and re-posts them,
because otherwise the badges and the money disagree. That's the right behaviour
and it was completely silent. The toast says so now: "Button on Dee - blinds taken
back and re-posted."

It also only does it when there is actually something posted to move. Before the
deal there is nothing committed, so passing the button is just moving a marker --
which is the whole point of the setup step.

Checked and deliberately left alone

Streets still advance one press at a time during an all-in run-out, which is right
-- the dealer is physically dealing the board between them. A player who joins
mid-hand is still marked folded for that hand and plays the next one. The
base-pot-amount shorthand still stands in for "everyone antes the same". Nothing
mutates state during a render.

4 tests (152 total): the preview matches what dealing produces with the button on
an empty chair, asking where the button will be doesn't move it, a button already
on a player is left alone, and an empty table doesn't break the question.

Driven through the UI: the prompt warns about the move and then the deal matches
it exactly; moving the button after the deal announces the re-post; and the blinds
survive being passed around three seats with the chip count fixed at 400. Layout
and contrast audits clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:06:58 -06:00
jheapsandClaude Opus 5 843ef9cd42 Wait to be told to deal, instead of guessing
Dealing and betting were one button. "Next Hand" moved the button and posted the
blinds in the same breath, so the moment between hands -- the one where people are
still sitting down, standing up and arguing about whose turn it is -- was spent
with money already committed.

That's where the guessing came from. The blind badges recompute from `inHand`
every render, but the posted chips are frozen from whenever beginHand ran, so the
two drift apart the instant the table changes:

  seat a fourth player after Next Hand
    badges said sb=s3 bb=s4, money on the table was s1=$2 s3=$1
    s4 was shown as the big blind having posted nothing

  take out the big blind after Next Hand
    badges moved to bb=s1, s4's $2 stayed in the pot, s1 had posted nothing

  press Next Hand twice
    burned a hand, moved the button twice, chips went 300 -> 297

Now the dealer says when. Next Hand advances the button and clears the table, and
stops. The bar shows who will post -- "Ben posts $1, Cal posts $2" -- and waits on
"Post blinds & deal". Enter does it too, so a dealer can carry a hand from setup
through the streets to showdown without reaching for the trackpad.

Until that press nothing is committed, so setting the table is free: seat someone,
take someone out, pass the button around, name the blinds by hand. The preview
follows every change and the chip count never moves. Settling a pot ends the hand
rather than starting the next one, for the same reason -- people cash out between
hands.

The preview is honest about what it can't do: fewer than two players and there's
no button at all, just what's missing.

A table saved before this existed has no flag, and guessing "not dealt" would
offer to deal over a live hand. Anything already committed, or anyone already on
the clock, means the betting was open when the tab was closed.

11 tests (148 total): the table waits, no money moves until the deal, seating and
unseating and passing the button all cost nothing, what the preview promises is
what gets posted, and a mid-hand save comes back mid-hand.

Driven through the real UI: seat a player and move the button while waiting, watch
the preview follow with the chip count fixed at 400, deal, play it out, settle, and
land back in setup. Triple-tapping Next Hand now costs nothing but button position
-- it used to cost a hand and three chips. Layout and contrast audits clean in both
the new setup state and after dealing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:51:43 -06:00
jheaps e5d5b515ba Merge pull request 'Hold'em dealer: nine polish rounds released together' (#57) from feature/casino-holdem-acting-seat into master
Build and Deploy / build-deploy (push) Successful in 1m22s
Releases #49-#57 in one go: pot safety, the banker's cash reconciliation, the short all-in rule, the standings name column, the layout and contrast audits, dealer puck hit area, illegal-raise warnings, and the acting-seat highlight.
2026-09-01 09:31:52 -06:00
jheaps a14087fa7d Merge pull request 'Stop rebuilding the hand log after every fold, call and raise' (#48) from feature/casino-holdem-render-perf into master
Build and Deploy / build-deploy (push) Successful in 1m27s
2026-09-01 09:30:13 -06:00
jheaps 76f1952d61 Merge pull request 'Make the overlays usable from a keyboard' (#47) from feature/casino-holdem-keyboard-focus into master
Build and Deploy / build-deploy (push) Canceled after 10s
2026-09-01 09:30:07 -06:00
jheaps f464e6f2e2 Merge pull request 'Make big numbers readable, and make the export add up' (#46) from feature/casino-holdem-resilience into master
Build and Deploy / build-deploy (push) Canceled after 5s
2026-09-01 09:30:02 -06:00
jheaps f5108de288 Merge pull request 'Handle the messy table: joining, leaving, and arguing about blinds' (#45) from feature/casino-holdem-messy-tables into master
Build and Deploy / build-deploy (push) Canceled after 0s
2026-09-01 09:29:58 -06:00
jheaps ee37cf7bd9 Merge pull request 'Make every tap in the dealer land instantly on a phone' (#44) from feature/casino-holdem-touch-mobile into master
Build and Deploy / build-deploy (push) Canceled after 10s
2026-09-01 09:29:53 -06:00
jheaps 0d1218fa53 Merge pull request 'Hold'em dealer: a test suite, one implementation each, and a pot-announcement fix' (#43) from chore/casino-holdem-tech-debt into master
Build and Deploy / build-deploy (push) Canceled after 0s
2026-09-01 09:29:49 -06:00
jheapsandClaude Opus 5 a72f199139 Light up the seat that's holding the game up
Asked what actually slows a home game down and the answer from people who deal
them is consistent: not how fast the cards come out, but players not noticing the
action is on them. "Waiting for shuffling and players not realizing it's their
action is going to cost much more time than how quickly you spread the flop."

So I measured how obvious the acting seat is, and it was distinguished by a rim
colour and nothing else -- 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. That is a subtle
thing to ask a dealer to spot across a table on a phone, for the one state the
whole pace of the game hangs on.

It reads as lit now rather than merely outlined: a solid gold ring at full
strength, a warm glow separating it from the felt, a warmer fill, and it sits
above its neighbours so the ring is never clipped by the seat next to it.

There's also a slow two-and-a-half second breath, which stops entirely under
prefers-reduced-motion -- the ring carries the whole job on its own for anyone who
would rather nothing moved, and that was checked rather than assumed.

The lift is box-shadow, not transform. A seat's transform is what positions it
around the felt, so setting one here would throw it off its spot -- the same trap
that nearly shipped a 38px seat displacement a few rounds ago.

One measurement I'm not claiming: the acting and live fills differ by only 1.1:1,
because the change is a hue shift rather than a lightness one. The ring and the
glow do the work; the fill just stops it looking like the same chip.

Checked at eight viewports: layout and contrast audits clean, exactly one acting
seat everywhere, and the lit seat is the same size as its neighbours at every
size -- the ring adds no layout. renderAll is 1.8ms with the pulse running, so it
costs nothing. Text on the warmer fill still passes contrast at every size.
137 tests passing.

Sources: pokerchipforum.com/threads/as-a-dealer-how-do-increase-more-hands-per-hour.114914/,
forumserver.twoplustwo.com/27/casino-amp-cardroom-poker/tips-advice-faster-better-dealer-241644/

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:20:46 -06:00
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
jheaps 6117886e70 Merge pull request 'Hold'em dealer: five correctness fixes and four polish passes (replaces #33–#41)' (#42) from feature/casino-holdem-dealer-batch into master
Build and Deploy / build-deploy (push) Successful in 1m12s
Reviewed-on: #42
2026-09-01 02:47:00 -06:00
13 changed files with 1163 additions and 49 deletions
+29 -2
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>
@@ -163,7 +163,7 @@
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<div class="seatPuckName"></div>
@@ -340,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>
+222 -16
View File
@@ -568,8 +568,19 @@ button,
.dealerChip {
position: absolute;
top: -10px;
right: -10px;
/* Inside its own seat, not on the rim.
It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval
of overlapping seats means hanging over the seat *next to it*. Measured on a
nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That
was always slightly true and the enlarged hit area made it much worse; three of
nine seats were losing taps, one of them losing its own middle.
Tucked inside, the overlap is zero at every size. It reads as a puck in the
corner of the seat rather than on its edge, which is a small price for tapping
a player and getting that player. */
top: 3px;
right: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
@@ -580,20 +591,70 @@ button,
font-size: 0.68rem;
cursor: pointer;
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:focus-visible {
opacity: 1;
.dealerChip::after {
content: "";
position: absolute;
/* Grows inward, over its own seat, not outward onto the neighbours.
The chip already sits outside its seat by design — top:-10px, right:-10px — so
a pad that also reaches up and right lands on whatever seat is next around the
oval. It did: three of nine seats had taps taken by a neighbour's chip, seat 1
losing 10 of 49 sampled points including its own centre, which meant tapping
the middle of seat 1 gave the button to seat 9.
Down and left is its own seat, which is a large target that can spare the
corner. The two pixels of outward reach are just to cover the chip's border. */
top: -3px;
right: -3px;
bottom: -18px;
left: -18px;
}
/* A poker table has one dealer button. This one drew nine.
Every seat carried a puck — the button's, and eight ghosts at 0.32 opacity that
were there to be pressed. Measured on a nine-seat table: eight pale discs, all
eight showing the letter D, one of them real. Finding the button meant comparing
nine identical shapes for the one that's gold, when it should be the only thing
of its kind on the felt. The ghosts also sat on top of their neighbours: every
remaining "something important is covered" offender on a crowded table was a
seat covered by the seat-next-door's ghost puck.
So a seat that doesn't have the button doesn't draw one. The affordance is still
there — hovering a seat fades its slot in, which is a preview of where the button
would go — and on touch, where there is no hover, "Give the button" has always
been in the seat's long-press menu. Keyboard reaches it by focus.
pointer-events matters as much as the opacity: an invisible puck that still takes
taps would be worse than a visible one. */
.dealerChip:not(.active) {
opacity: 0;
pointer-events: none;
}
@media (hover: hover) {
.seat:hover .dealerChip {
opacity: 1;
.seat:hover .dealerChip:not(.active) {
opacity: 0.5;
pointer-events: auto;
}
}
.dealerChip:not(.active):focus-visible {
opacity: 1;
pointer-events: auto;
}
.dealerChip.active {
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
border-color: #a9782f;
@@ -621,16 +682,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
@@ -2003,12 +2073,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 {
@@ -2146,6 +2254,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) {
@@ -2719,9 +2831,20 @@ body.hasActionBar #toastHost {
against the containing block's width, not the element's own, so narrowing the
wrap on its own left it full height regardless. */
#pokerTableWrap {
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
width: min(100%, calc(var(--feltRoom) / 0.72));
/* A wide ellipse, which is what a real poker table is.
Capping the width by the height to hold the portrait aspect ratio was
throwing away most of the screen: at 640x360 the felt came out 135px wide
inside a 601px column — 22% of the width — leaving 366px of rim for nine
seats that need 662px. Adjacent seat centres ended up 13px apart, which is
why a seat's centre belonged to its neighbour.
Landscape has width and no height, so the table takes the width it has and
is only as tall as the room left over. */
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem);
width: 100%;
height: var(--feltRoom);
min-height: 7rem;
padding-bottom: 0;
}
@@ -2758,6 +2881,7 @@ body.hasActionBar #toastHost {
width: 62px;
min-height: 40px;
}
}
/* ---------- Short phones, upright ----------
@@ -2863,3 +2987,85 @@ textarea:focus-visible {
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;
}
}
/* ---------- The deal step ----------
Between hands the bar shows who will post and waits. Dealing used to happen the
instant the previous hand ended, which committed blinds while people were still
sitting down — and left the blind badges disagreeing with the money already in. */
.dealPrompt {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dealPromptLine {
margin: 0;
text-align: center;
font-size: 0.8rem;
color: #c8c8c8;
line-height: 1.35;
}
.dealPromptLine.dealPromptBlocked {
color: #9a9a9a;
}
.actionBtn.actionDeal {
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
font-weight: 600;
}
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
would otherwise only discover afterwards. */
.dealPromptLine.dealPromptAside {
font-size: 0.72rem;
color: #9a9a9a;
}
@@ -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;
}
@@ -40,7 +40,9 @@ const ActionBar = {
publish();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
// still holds during setup, because a seated player is a live seat whether or not
// the betting has been opened. An empty table stays uncluttered.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
},
@@ -66,7 +68,9 @@ const ActionBar = {
const breakdown = this.renderPotBreakdown();
if (breakdown) host.appendChild(breakdown);
if (Betting.isHandOver(this.state)) {
if (!this.state.bettingOpen) {
host.appendChild(this.renderDealPrompt());
} else if (Betting.isHandOver(this.state)) {
host.appendChild(this.renderHandOver());
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
host.appendChild(this.renderStreetDone());
@@ -319,12 +323,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 +378,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,6 +395,123 @@ 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;
},
// The table is set but the cards aren't out. This is the step that used to happen
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
const preview = Betting.blindPreview(this.state);
const seated = Betting.liveSeats(this.state).length;
const line = document.createElement("p");
line.className = "dealPromptLine";
if (!preview) {
// Two players is the least you can deal. Say what's missing rather than
// offering a button that would quietly do nothing.
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
: "Only one player is dealt in — a hand needs two.";
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
return wrap;
}
const name = i => HoldemState.displayName(this.state.seats[i], i);
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
wrap.appendChild(line);
// Dealing relocates a button sitting on an empty chair. That has to be said
// before it happens rather than discovered afterwards — a fresh table starts
// the button on seat 1 whether or not anyone is sitting there.
if (preview.buttonMoves) {
const moved = document.createElement("p");
moved.className = "dealPromptLine dealPromptAside";
moved.textContent =
`Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`;
wrap.appendChild(moved);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
return wrap;
},
@@ -29,6 +29,31 @@ const Betting = {
.filter(s => s.player.inHand && !s.player.folded);
},
// Who would post if the hand were dealt right now. Used to preview the blinds
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
blindPreview(state) {
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
// guessing this whole step exists to remove.
const buttonIndex = HoldemState.buttonSeatForDeal(state);
const asDealt = buttonIndex === state.dealerSeatIndex
? state
: { ...state, dealerSeatIndex: buttonIndex };
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
};
},
committed(player) {
return this.round(player.committed || 0);
},
@@ -291,7 +316,12 @@ const Betting = {
// Deals a fresh betting round: blinds posted, action to the seat after the big
// blind, which is where a dealer expects to be looking when the cards go out.
//
// Called when the dealer says the table is ready, not automatically when the
// previous hand ends — everything before this point is setup, where seats and the
// button can move freely because no money has been committed.
beginHand(state) {
state.bettingOpen = true;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
+22 -11
View File
@@ -96,13 +96,21 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
// Before anyone has acted the blinds are still just sitting there, so
// moving the button moves them too — a home game passes it round several
// times before the cards come out, and the posted chips have to follow.
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
if (willRepost) Betting.repostBlinds(state);
});
},
assignBlind: (index, which) => {
const blinds = HoldemState.getBlindSeats(state);
@@ -159,8 +167,10 @@ document.addEventListener("DOMContentLoaded", () => {
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
}
// Settling a pot ends the hand; it doesn't start the next one. The
// table goes back to setup so people can sit down or cash out before
// any blinds are committed.
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
};
@@ -222,7 +232,9 @@ document.addEventListener("DOMContentLoaded", () => {
return true;
},
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"),
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
// streets and showdown without reaching for the trackpad.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
undo: () => pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"),
});
@@ -345,9 +357,8 @@ document.addEventListener("DOMContentLoaded", () => {
? ` ${Money.format(unresolved)} taken off the table.`
: "";
runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => {
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
@@ -174,8 +174,18 @@ const Render = {
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex);
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
const chipLabel = hasButton
? `${who} has the dealer button`
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
// Tapping the puck is the same act as "Give the button" in the seat menu,
// so it goes through the same hook — otherwise this one path would move
// the button without moving the blinds that follow it.
@@ -40,6 +40,22 @@ const Shortcuts = {
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
// Enter and Space are how a keyboard presses the control it is already sitting on.
// Claiming them for "advance" didn't just block that press — it ran a different
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
// the next hand. Space on the same button worked, because "advance" happened to
// decline that time and the browser's own activation got through. A control that
// does something else every other press is worse than one that does nothing.
//
// Only these two keys are handed back. The letter shortcuts stay live with a
// button focused, because a button does nothing with "f" and the dealer's hand
// shouldn't have to leave them.
focusedControlOwnsKey(key) {
if (key !== "enter" && key !== " ") return false;
const el = document.activeElement;
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
},
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -47,6 +63,8 @@ const Shortcuts = {
const action = this.KEYS[key];
if (!action) return;
if (this.focusedControlOwnsKey(key)) return;
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
@@ -58,6 +58,20 @@ const HoldemState = {
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
// Whether the betting has actually been opened for this hand.
//
// Dealing and betting used to be one button: "Next Hand" moved the button
// and posted the blinds in the same breath. That meant the moment between
// hands — the one where people are still sitting down, standing up and
// arguing about whose turn it is — was spent with money already committed,
// and the blind badges recomputing off `inHand` while the posted chips
// stayed frozen from when the hand began. Seat somebody after pressing it
// and the table would show a big blind who had posted nothing.
//
// So the dealer says when. False means the table is being set up: nothing
// is committed, the badges are a preview, and seating or moving the button
// costs nothing.
bettingOpen: false,
// Set when the dealer names the blinds by hand instead of letting them
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
// ("you had it last time", someone arriving late owing a blind), and the
@@ -84,6 +98,17 @@ const HoldemState = {
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
// progress. Anything already committed, or anyone already on the clock,
// means the betting was open when they closed the tab.
if (parsed.bettingOpen === undefined) {
parsed.bettingOpen =
parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined ||
parsed.seats.some(p =>
(Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0);
}
parsed.seats.forEach(player => {
if (player.stack === undefined) player.stack = null;
if (player.committed === undefined) player.committed = 0;
@@ -248,15 +273,25 @@ const HoldemState = {
// position off it. On the very first hand of a fresh table it starts on seat 1
// whether or not anybody is sitting there, and a table that empties out around it
// leaves it stranded. Move it to a real player before a hand is dealt.
ensureButtonOnPlayer(state) {
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
// Where the button will be when the hand is dealt — without moving it.
//
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
// to ask the question without answering it. It couldn't, and so the preview lied:
// with the button parked on an empty chair it promised one pair of blinds and
// dealing produced another, because beginHand relocates the button first and then
// works out who posts.
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) {
state.dealerSeatIndex = index;
return;
}
if (state.seats[index].inHand) return index;
}
return state.dealerSeatIndex;
},
ensureButtonOnPlayer(state) {
state.dealerSeatIndex = this.buttonSeatForDeal(state);
},
// A hand-picked blind is only usable if that seat is actually in the hand — a
@@ -306,6 +341,12 @@ const HoldemState = {
startNextHand(state) {
this.advanceButton(state);
// Cards aren't out and nothing is posted until the dealer says so.
state.bettingOpen = false;
state.actingSeatIndex = null;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// A hand-picked blind applies to the hand it was picked for. Carrying it into
// the next one would freeze the blinds on two players for the rest of the
// night, which is the opposite of what naming them once is for.
+15 -1
View File
@@ -31,7 +31,7 @@ in a browser: whether a dealer can actually reach every control.
node tests/holdem/runUiAudit.js # needs Playwright and the site running
```
It checks touch-action and tap highlights, that fields stay selectable and are large
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
@@ -43,6 +43,20 @@ this one needs a browser and a running site. Run it after any change that adds o
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
+205
View File
@@ -0,0 +1,205 @@
// Dealing and betting are two decisions, and fusing them meant the table guessed.
//
// The moment between hands is when people sit down, stand up and argue about whose
// turn it is. With blinds posted the instant the previous hand ended, the blind
// badges recomputed off `inHand` while the committed chips stayed frozen — so seating
// somebody produced a big blind who had posted nothing.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const postedBy = state => state.seats
.map((p, i) => (Betting.committed(p) > 0 ? i : null))
.filter(i => i !== null);
describe("the table waits to be dealt", () => {
test("finishing a hand leaves the next one un-dealt", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
assert.equal(state.bettingOpen, true);
HoldemState.startNextHand(state);
assert.equal(state.bettingOpen, false, "the next hand hasn't been dealt");
assert.equal(state.actingSeatIndex, null, "nobody is on the clock");
assert.deepEqual(postedBy(state), [], "and no blind has been committed");
});
test("no money moves until the dealer deals", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
const start = chipsOnTable(state);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), start);
assert.equal(HoldemState.totalPot(state), 0);
Betting.beginHand(state);
assert.equal(state.bettingOpen, true);
assert.equal(postedBy(state).length, 2, "now the blinds are in");
assert.equal(chipsOnTable(state), start, "and nothing was created doing it");
});
});
describe("setting the table costs nothing", () => {
test("seating a player before the deal changes who posts", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
HoldemState.startNextHand(state);
const before = Betting.blindPreview(state);
state.seats[3].inHand = true;
state.seats[3].stack = 100;
const after = Betting.blindPreview(state);
assert.notDeepEqual(
[before.sbIndex, before.bbIndex],
[after.sbIndex, after.bbIndex],
"the preview follows the new table"
);
assert.deepEqual(postedBy(state), [], "and still nothing is committed");
Betting.beginHand(state);
assert.deepEqual(postedBy(state).sort(), [after.sbIndex, after.bbIndex].sort(),
"the money lands where the preview said it would");
});
test("removing a player before the deal can't strand their blind", () => {
// The old failure: take out the big blind after the hand had begun and their
// chips stayed in the pot while the badge moved to somebody who'd posted
// nothing. Before the deal there is nothing to strand.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
HoldemState.startNextHand(state);
const doomed = Betting.blindPreview(state).bbIndex;
state.seats[doomed].inHand = false;
assert.deepEqual(postedBy(state), [], "nobody has posted, so nothing is stranded");
Betting.beginHand(state);
const posted = postedBy(state);
assert.ok(!posted.includes(doomed), "the player who left posts nothing");
posted.forEach(i => assert.ok(state.seats[i].inHand, "only players in the hand post"));
});
test("moving the button before the deal just moves it", () => {
const state = makeTable({ seatCount: 8, seated: [0, 2, 4, 6], dealerSeatIndex: 0 });
const start = chipsOnTable(state);
HoldemState.startNextHand(state);
[2, 4, 6, 0].forEach(seat => {
state.dealerSeatIndex = seat;
assert.deepEqual(postedBy(state), [], `passing the button to seat ${seat + 1} posted something`);
assert.equal(chipsOnTable(state), start);
});
Betting.beginHand(state);
assert.equal(chipsOnTable(state), start, "dealing moves chips between stacks, not into thin air");
});
test("what the preview promises is what gets posted", () => {
const state = makeTable({ seatCount: 9, seated: [1, 3, 6, 8], dealerSeatIndex: 1 });
HoldemState.startNextHand(state);
const preview = Betting.blindPreview(state);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
});
test("there's nothing to preview without two players", () => {
const alone = makeTable({ seatCount: 6, seated: [2] });
HoldemState.startNextHand(alone);
assert.equal(Betting.blindPreview(alone), null);
const empty = makeTable({ seatCount: 6, seated: [] });
HoldemState.startNextHand(empty);
assert.equal(Betting.blindPreview(empty), null);
});
test("a hand-picked blind shows up in the preview", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
HoldemState.startNextHand(state);
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
const preview = Betting.blindPreview(state);
assert.equal(preview.manual, true);
assert.equal(preview.sbIndex, 3);
assert.equal(preview.bbIndex, 0);
});
});
describe("a table saved mid-hand comes back mid-hand", () => {
const loadWith = saved => {
const original = globalThis.localStorage;
globalThis.localStorage = { getItem: () => JSON.stringify(saved), setItem: () => {}, removeItem: () => {} };
const state = HoldemState.load();
globalThis.localStorage = original;
return state;
};
test("chips already committed mean the betting was open", () => {
const state = loadWith({
seats: [{ id: "a", name: "Ann", inHand: true, committed: 2 }, { id: "b", name: "Ben", inHand: true, committed: 0 }],
handNumber: 3,
});
assert.equal(state.bettingOpen, true, "otherwise it would offer to deal over a live hand");
});
test("somebody on the clock means the betting was open", () => {
const state = loadWith({
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
actingSeatIndex: 1, handNumber: 2,
});
assert.equal(state.bettingOpen, true);
});
test("a quiet table comes back waiting to be dealt", () => {
const state = loadWith({
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
actingSeatIndex: null, handNumber: 5,
});
assert.equal(state.bettingOpen, false);
});
});
describe("the preview tells the truth about the button", () => {
test("a button parked on an empty chair is answered for where it will be", () => {
// The preview used to read the button where it sat, while dealing relocated it
// first — so it promised one pair of blinds and produced another.
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
state.bettingOpen = false;
assert.equal(state.seats[0].inHand, false, "the button is on an empty chair");
const preview = Betting.blindPreview(state);
assert.equal(preview.buttonMoves, true, "and the preview knows it will move");
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said");
});
test("asking where the button will be doesn't move it", () => {
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
state.bettingOpen = false;
Betting.blindPreview(state);
Betting.blindPreview(state);
assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action");
});
test("a button already on a player is left alone", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.bettingOpen = false;
const preview = Betting.blindPreview(state);
assert.equal(preview.buttonMoves, false);
assert.equal(preview.buttonIndex, 0);
});
test("buttonSeatForDeal copes with a table nobody is sitting at", () => {
const state = makeTable({ seatCount: 6, seated: [] });
assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex);
});
});
+110
View File
@@ -0,0 +1,110 @@
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
//
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
// rather than a whole DOM: the only things it asks about are what has focus and
// whether an overlay is open.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const Shortcuts = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
// A stand-in for a focused element. `matches` answers the same selector list the
// real one would, which is the whole of what focusedControlOwnsKey looks at.
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
return {
tagName,
isContentEditable,
matches: selector => selectors.some(s => selector.includes(s)),
};
}
const BUTTON = element("BUTTON", { selectors: ["button"] });
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
const LINK = element("A", { selectors: ["a[href]"] });
const DIV = element("DIV");
const TEXT_FIELD = element("INPUT");
// Runs one keypress against a fresh Shortcuts and reports which action fired.
function press(key, { focused = DIV, overlays = [] } = {}) {
const fired = [];
let defaultPrevented = false;
globalThis.document = {
activeElement: focused,
addEventListener() {},
querySelectorAll: () => overlays,
};
const handlers = {};
for (const action of Object.values(Shortcuts.KEYS)) {
handlers[action] = () => { fired.push(action); return true; };
}
Shortcuts.handlers = handlers;
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
return { fired: fired[0] || null, defaultPrevented };
}
test.after(() => { delete globalThis.document; });
test("Enter on a focused button is left to the button", () => {
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
});
test("Space on a focused button is left to the button", () => {
assert.equal(press(" ", { focused: BUTTON }).fired, null);
});
test("a key left to the control is not swallowed either", () => {
// preventDefault would stop the browser activating the button, which is the
// half of the bug that turned "wrong action" into "no action".
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
});
test("summary and links own Enter and Space too", () => {
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
assert.equal(press(" ", { focused: LINK }).fired, null);
});
test("Space still advances when nothing pressable has focus", () => {
assert.equal(press(" ", { focused: DIV }).fired, "advance");
});
test("Enter still advances when nothing pressable has focus", () => {
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
});
test("the letter shortcuts stay live with a button focused", () => {
// A button does nothing with "f", so there is no press to hand back — and a
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
});
test("typing still silences everything but Escape", () => {
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
});
test("an open overlay still owns the keyboard", () => {
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
});
test("a focused control wins before the overlay check, not after", () => {
// Both would decline here; the point is that neither path fires an action.
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
});
test("modified keys are never shortcuts", () => {
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
const fired = [];
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
assert.deepEqual(fired, []);
});
+306 -9
View File
@@ -133,7 +133,18 @@ const LayoutAudit = {
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;
//
// But only the seat's *body* is harmless. A neighbouring seat's own
// controls sitting on top of this one is a control being stolen, and
// exempting the whole seat subtree hid exactly that: an enlarged
// dealer-chip hit area was taking the centre of the seat three
// places round the table, and this check called it crowding and
// moved on.
const neighbourSeat = top.closest && top.closest(".seat");
if (neighbourSeat) {
const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']");
if (!isControl) return;
}
blocked++;
if (!blockedBy) blockedBy = this.describe(top);
});
@@ -231,22 +242,306 @@ const LayoutAudit = {
return { name: "no field small enough to trigger iOS zoom", offenders };
},
// ---- Contrast ----
//
// This check has been got wrong twice before, both times by being too clever about
// backgrounds. The first version read only `background-color`, so every sheet
// painted with a gradient was measured against transparency and the dealer puck's
// dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
// treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
//
// So it is built to be incapable of false positives instead of trying to be exact.
// Backgrounds composite properly through alpha, and where a gradient is involved
// every one of its colour stops is tried and the *most flattering* one is used. A
// finding therefore means the text fails even under the reading most generous to
// it, which is a claim worth making. Text that can't be resolved to any opaque
// background is reported separately as unmeasured rather than guessed at.
parseColour(value) {
const match = String(value).match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
},
// Colour stops out of any gradient in a background-image.
gradientStops(backgroundImage) {
if (!backgroundImage || backgroundImage === "none") return [];
return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
.map(c => this.parseColour(c))
.filter(Boolean);
},
over(top, bottom) {
const a = top.a;
return {
r: top.r * a + bottom.r * (1 - a),
g: top.g * a + bottom.g * (1 - a),
b: top.b * a + bottom.b * (1 - a),
a: 1,
};
},
relativeLuminance({ r, g, b }) {
const channel = v => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
},
contrastRatio(fg, bg) {
const a = this.relativeLuminance(fg);
const b = this.relativeLuminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
},
// Every background this text could actually be sitting on.
//
// Layers are collected from the element outwards and stop at the first fully
// opaque one, because nothing behind that is visible. Getting this wrong in the
// lenient direction is just as bad as the old false positives: an earlier version
// kept walking past an opaque gradient and offered the page background as a
// candidate, which scored white-on-a-mid-grey-panel as though it were
// white-on-near-black and would have waved real problems through.
//
// A gradient contributes one candidate per colour stop; translucent layers above
// the opaque one are composited onto each candidate in order.
backgroundCandidates(el) {
const translucent = []; // top-down, above the opaque layer
let base = null; // candidate colours for the first opaque layer
for (let node = el; node && !base; node = node.parentElement) {
const style = getComputedStyle(node);
const stops = this.gradientStops(style.backgroundImage);
const colour = this.parseColour(style.backgroundColor);
// A gradient whose stops are all opaque hides everything behind it.
if (stops.length > 0 && stops.every(s => s.a === 1)) {
base = stops;
break;
}
stops.forEach(s => { if (s.a > 0) translucent.push(s); });
if (colour && colour.a === 1) { base = [colour]; break; }
if (colour && colour.a > 0) translucent.push(colour);
}
if (!base) {
// Nothing opaque anywhere up the tree — fall back to the page's own colour
// so a finding is still measured against something real.
const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
}
// Fold the translucent layers back on, innermost last so it lands on top.
return base.map(candidate => {
let composed = candidate;
for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
return composed;
});
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
const nodes = selector
? [...document.querySelectorAll(selector)]
: [...document.querySelectorAll("*")];
nodes.forEach(el => {
if (el.children.length > 0) return; // leaf text only
const text = (el.textContent || "").trim();
if (!text) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const style = getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none") return;
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
// Decoration is exempt by definition: the watermark on the felt is meant
// to be barely there, and holding it to a reading threshold would mean
// making it not a watermark. Marked aria-hidden so screen readers skip it
// too, which is the same claim expressed where it also does some good.
if (el.closest("[aria-hidden='true']")) return;
const fgRaw = this.parseColour(style.color);
if (!fgRaw) return;
const candidates = this.backgroundCandidates(el);
if (candidates.length === 0) {
unmeasured.push(`${this.describe(el)} :: no opaque background found`);
return;
}
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
best = Math.max(best, this.contrastRatio(fg, bg));
});
const sizePx = parseFloat(style.fontSize);
const weight = parseInt(style.fontWeight, 10) || 400;
const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
const required = isLarge ? largeMinimum : minimum;
if (best + 0.05 < required) {
offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
}
});
return { name: "text meets WCAG AA contrast", offenders, unmeasured };
},
// ---- Reach (touch target sizes) ----
touchTargets(minimum = 44) {
const offenders = this.visibleControls()
// 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 => {
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
const rect = el.getBoundingClientRect();
return rect.height < minimum - 0.5 || rect.width < minimum - 0.5;
})
.map(el => {
const rect = el.getBoundingClientRect();
return `${this.describe(el)} :: ${Math.round(rect.width)}x${Math.round(rect.height)}`;
// 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 };
},
// Tapping the middle of a seat must give you that seat.
//
// Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew
// outward from a chip that already hung over its seat's corner — and therefore
// over the seat next to it. On a nine-seat table, tapping the centre of seat 1
// handed the button to seat 9. The occlusion check missed it because it treated
// anything inside a `.seat` as harmless crowding.
//
// Reported separately from occlusion because the cause is different: not an
// overlay on top of the table, but two pieces of the table fighting over a tap.
seatOwnership() {
const seats = [...document.querySelectorAll(".seat")];
const offenders = [];
seats.forEach((seat, index) => {
const rect = seat.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return;
const hit = document.elementFromPoint(x, y);
if (!hit || seat.contains(hit) || hit === seat) return;
const other = hit.closest(".seat");
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
const control = hit.matches && hit.matches("button, a, input, select, [role='button']");
offenders.push(
`seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}`
);
});
return { name: "a seat's centre belongs to that seat", offenders };
},
// A poker table has one dealer button. Nine pucks all showing "D", one of them
// gold, turns the most-read marker on the felt into a spot-the-difference — and
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
// Both halves are checked here: what's drawn, and what it announces itself as.
oneDealerButton() {
const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => {
const style = getComputedStyle(chip);
return style.display !== "none" && style.visibility !== "hidden" &&
parseFloat(style.opacity) > 0.05;
});
const offenders = [];
if (shown.length > 1) {
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
}
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
if (duplicated.length) {
offenders.push(
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
);
}
return { name: "the felt shows one dealer button", offenders };
},
// ---- Runner ----
runAll(options = {}) {
@@ -259,6 +554,8 @@ const LayoutAudit = {
this.textSpill(),
this.horizontalOverflow(),
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));