Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen #117

Merged
jheaps merged 4 commits from polish/holdem-offline-and-render into master 2026-09-03 12:24:00 -06:00
Owner

Six findings across four rounds, all measured on the running page. One PR into master.

1. The Confirm button was below a scroll

The pot wizard is the one dialog in this tool that divides money, and its Confirm button sat below a scroll at every screen size — measured with six players and a side pot:

dialog below the fold
iPhone 14 390×844 244px
Galaxy S8 360×640 417px
844×390 landscape 583px
desktop 1280×900 172px

Nothing on screen said there was anything under the list of names, so a dealer with the table waiting had to scroll a list of players to find the only control that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious answer and it cannot work here: the actions were the last child of the scrolling content, so their containing block ended exactly where they did and bottom had no room to lift them. Sticky on the header did work, for the mirror-image reason, which is what made the failure confusing to read. Capping the player list wasn't enough either — the breakdown and log still overflowed on a short screen.

The dialog is a proper column now: a title that doesn't move, a body that scrolls, a footer outside the scroll. Confirm on screen and hit-testable at all six configurations, twelve-handed on a Galaxy S8 included, and the dialog itself no longer scrolls at all.

The checkboxes are painted too — accent-color only ever coloured the tick, leaving the platform's bright white box as the loudest thing on a near-black dialog and the last raw control in a tool where everything else had been drawn.

2. The table wasn't big enough for the game around it

Seats were hanging past the table's outer edge into the page — 5 of 9 at 1280×900 (worst 49px), all six in landscape. The ring sat at a flat 44%×42% at every size; a fixed pair can only be right for one ratio of puck to felt, so it was a different amount of wrong on every screen.

Now derived from what has to fit. Worst overhang 49px → 9px; 59 of 65 configurations completely clean. Where nothing fits it prefers a small overhang over overlapping seats — a dealer can see an overhang; they can't see that the seat they pressed wasn't the one that answered.

Residual, reported not tuned away: six configurations overhang 1–9px, all landscape at 640×360 and 667×375. The check is left strict so they stay visible.

3. The first screen a dealer ever sees

The word EMPTY, in capitals, ten times across the felt, with Resolve Pot as the accent-green primary on a table with no players. Now: quiet numbered chairs, a "Nobody's at the table yet." prompt with one full-width Seat a player button, and the two dead controls disabled rather than shouting.

4. The tool needed the internet to exist

Verified before: offline reload was net::ERR_INTERNET_DISCONNECTED. Now installable and fully offline — 19 assets from one visit, then a whole hand played with the network cut, $800 conserved.

5. A screen reader read the whole bar after every tap

One tap announced the street, pot, player count, who's short, the clock, the undo label and all three buttons. Now one sentence: "Dee to act, $10 to call. Pot $15."

6. Every tap rebuilt ninety DOM nodes

nine-handed original now
renderSeats 7.3ms 1.8ms
renderAll 12.4ms 6.8ms

Two new permanent audit checks

  • "seats and chips stay on the table" — fault-injected: restoring the flat ring reproduces seat 3 at 49px.
  • "an open dialog's buttons are on screen" — hit-tests rather than trusting geometry, since a button can be in the viewport and still be under something. Fault-injected: putting the footer back inside the scroll reports Confirm 105px below the fold.

Checked and deliberately left alone

  • The standings table hides three columns on narrow screens via a container query keyed to the sidebar, not the viewport — which is why a desktop shows fewer columns than an iPad. Correct as written.
  • In/Won/Net read $0 for hands settled outside the wizard; the comment above them already explains it.
  • ViewData["cssVersion"] renders empty on every page of the site. Inert — Program.cs sends Cache-Control: no-store on all static files.

Bugs of my own, caught by walking the flows

  • After typing a name, both header buttons stayed greyed out — commitSeatChange refreshes narrowly on purpose, and the comment above it already warned about this exact trap.
  • My own token guard read scroll-padding as padding and failed the spacing scale — correct by its rules, wrong in substance, since those are a sticky bar's measured height. Fixed and re-fault-injected.
  • Four false alarms chased and dismissed rather than "fixed".

Validated

  • 619 unit tests, 0 failures.
  • 65 layout audits (13 viewports × 5 table sizes) plus 35 overlay audits.
  • The wizard walked end to end: side pot resolved, hand logged, $1,560 on the table before and after.
  • Offline re-verified after every round.
Six findings across four rounds, all measured on the running page. One PR into `master`. ## 1. The Confirm button was below a scroll The pot wizard is the one dialog in this tool that divides money, and its **Confirm button sat below a scroll at every screen size** — measured with six players and a side pot: | | dialog below the fold | |---|---| | iPhone 14 390×844 | **244px** | | Galaxy S8 360×640 | **417px** | | 844×390 landscape | **583px** | | desktop 1280×900 | **172px** | Nothing on screen said there was anything under the list of names, so a dealer with the table waiting had to scroll a list of players to find the only control that finishes the hand. **Two goes at this, and the first was wrong.** A sticky footer is the obvious answer and it cannot work here: the actions were the *last child* of the scrolling content, so their containing block ended exactly where they did and `bottom` had no room to lift them. Sticky on the header *did* work, for the mirror-image reason, which is what made the failure confusing to read. Capping the player list wasn't enough either — the breakdown and log still overflowed on a short screen. The dialog is a proper column now: a title that doesn't move, a body that scrolls, a footer outside the scroll. **Confirm on screen and hit-testable at all six configurations**, twelve-handed on a Galaxy S8 included, and the dialog itself no longer scrolls at all. The checkboxes are painted too — `accent-color` only ever coloured the tick, leaving the platform's bright white box as the loudest thing on a near-black dialog and the last raw control in a tool where everything else had been drawn. ## 2. The table wasn't big enough for the game around it Seats were hanging past the table's outer edge into the page — **5 of 9 at 1280×900 (worst 49px)**, all six in landscape. The ring sat at a flat 44%×42% at every size; a fixed pair can only be right for one ratio of puck to felt, so it was a *different* amount of wrong on every screen. Now derived from what has to fit. **Worst overhang 49px → 9px; 59 of 65 configurations completely clean.** Where nothing fits it prefers a small overhang over overlapping seats — a dealer can see an overhang; they can't see that the seat they pressed wasn't the one that answered. **Residual, reported not tuned away:** six configurations overhang 1–9px, all landscape at 640×360 and 667×375. The check is left strict so they stay visible. ## 3. The first screen a dealer ever sees **The word EMPTY, in capitals, ten times across the felt**, with *Resolve Pot* as the accent-green primary on a table with no players. Now: quiet numbered chairs, a *"Nobody's at the table yet."* prompt with one full-width **Seat a player** button, and the two dead controls disabled rather than shouting. ## 4. The tool needed the internet to exist Verified before: offline reload was `net::ERR_INTERNET_DISCONNECTED`. Now installable and fully offline — 19 assets from one visit, then **a whole hand played with the network cut, $800 conserved**. ## 5. A screen reader read the whole bar after every tap One tap announced the street, pot, player count, who's short, the clock, the undo label and all three buttons. Now one sentence: *"Dee to act, $10 to call. Pot $15."* ## 6. Every tap rebuilt ninety DOM nodes | nine-handed | original | now | |---|---|---| | `renderSeats` | 7.3ms | **1.8ms** | | `renderAll` | 12.4ms | **6.8ms** | ## Two new permanent audit checks - **"seats and chips stay on the table"** — fault-injected: restoring the flat ring reproduces seat 3 at 49px. - **"an open dialog's buttons are on screen"** — hit-tests rather than trusting geometry, since a button can be in the viewport and still be under something. Fault-injected: putting the footer back inside the scroll reports Confirm 105px below the fold. ## Checked and deliberately left alone - The standings table hides three columns on narrow screens via a **container query keyed to the sidebar, not the viewport** — which is why a desktop shows fewer columns than an iPad. Correct as written. - In/Won/Net read `$0` for hands settled outside the wizard; the comment above them already explains it. - `ViewData["cssVersion"]` renders empty on every page of the site. Inert — `Program.cs` sends `Cache-Control: no-store` on all static files. ## Bugs of my own, caught by walking the flows - After typing a name, both header buttons stayed greyed out — `commitSeatChange` refreshes narrowly on purpose, and the comment above it already warned about this exact trap. - My own token guard read `scroll-padding` as `padding` and failed the spacing scale — correct by its rules, wrong in substance, since those are a sticky bar's measured height. Fixed and re-fault-injected. - Four false alarms chased and dismissed rather than "fixed". ## Validated - **619 unit tests, 0 failures.** - **65 layout audits** (13 viewports × 5 table sizes) plus **35 overlay audits**. - The wizard walked end to end: side pot resolved, hand logged, **$1,560 on the table before and after**. - Offline re-verified after every round.
jheaps added 1 commit 2026-09-03 10:52:01 -06:00
Three findings this round, all measured on the running page.

**1. The tool needed the internet to exist.**

The night already survived anything — it lives in localStorage — but the page
did not. Reload in somebody's back room with one bar of signal and a dealer
holding a phone in one hand and a deck in the other got a browser error page
with nine players waiting. Verified: with no worker, an offline reload is
net::ERR_INTERNET_DISCONNECTED.

There is now a service worker (wwwroot/casino/sw.js) and a manifest, so the
tool installs to a home screen and runs with the network switched off. Measured
end to end: 19 assets cached from one ordinary visit, then with the context
offline the page loads, draws six seats, applies its CSS, and plays a whole
hand — blinds posted, four calls, $800 still on the table.

Three decisions worth writing down:

  * The precache list is read off the document rather than kept in the worker.
    Every stylesheet and script is already in the DOM; a hand-written file list
    stays right until somebody adds a script tag, and then the tool is quietly
    offline-broken until a dealer finds out at a table.
  * No skipWaiting. A new worker taking over a live page can hand it assets
    from a different release than the ones it started with, and this page loads
    seventeen scripts that expect to agree with each other.
  * Stale-while-revalidate for assets, not cache-first — see finding 3.

Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
prefix of the page it controls and "/casino/texasholdem" does not start with
"/casino/texasholdem/". Anything in scope the worker wasn't told about goes
straight to the network.

Program.cs needed .webmanifest added to the MIME map: an unknown extension is
not served with the wrong type, it is not served at all, and a 404 manifest is
exactly the silent failure that stops a browser ever offering "Add to Home
Screen".

**2. A screen reader read out the whole action bar after every tap.**

The bar carried aria-live on the element whose entire contents render() rebuilds
each action. Measured, after one tap:

  "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
   Fold Call $10 Raise"

Nothing in it is wrong; it is unusable at the speed a hand is dealt, and it is
the documented anti-pattern — a live region has to be a stable element whose
text changes, not a container that gets replaced. There is now a small
role="status" element that exists from page load and says one sentence:
"Dee to act, $10 to call. Pot $15." Guarded on the sentence rather than on the
state, because render() runs for many reasons that don't change whose turn it
is, and a live region handed the same words twice reads them twice.

**3. Every tap rebuilt ninety DOM nodes.**

renderSeats did `innerHTML = ""` and then cloned a template, attached three sets
of listeners and set a dozen attributes per chair — on every render, and
renderAll runs after every action. Profiled on a nine-handed table:

  renderSeats   7.3ms -> 4.0ms
  renderAll    12.4ms -> 9.5ms

The pucks are built once and updated in place, which is what the rest of that
function was already doing to their contents. A seat's node belongs to its index
for as long as the table is that size, so the listeners still close over the
index; only a change in the number of chairs rebuilds the row. Verified that
listeners don't stack: one click after six renders fires the hook exactly once,
and resizing 9 -> 4 -> 10 renumbers correctly.

Found along the way and deliberately left alone: ViewData["cssVersion"] renders
empty on every page of the site, so the ?v= cache-busting has never worked. It
is inert — Program.cs sends Cache-Control: no-store on all static files — and
the service worker was the only reason to care, which is why it uses
stale-while-revalidate instead. Noted in TexasHoldem.cshtml.

608 tests, 0 failures, including a new announcing.test.js. 52 layout audits
across 13 viewports x 4 table sizes: 0 failures, and the action bar still
doesn't change height mid-hand. Gestures re-verified after the seat refactor:
double-tap fold, long-press menu, give-the-button, and the active dealer chip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-03 11:14:39 -06:00
The first screen a dealer ever sees was the one nobody had looked at. Opened
fresh, with nothing saved, the tool showed:

  * the word EMPTY, in capitals, ten times across the felt
  * Resolve Pot as the accent-green primary — on a table with no players and no
    pot, so the loudest control on screen was the one that could do least
  * Next Hand, with no button to move
  * no indication anywhere of what to do first, except a grey caption under the
    felt saying "tap a seat to edit"

An empty state is the one screen a new user is guaranteed to meet, and this one
described itself ten times without once saying what to do.

**The felt stops repeating itself.** An open chair is already dashed instead of
solid, faded, and has no stack under it — that reads at a glance, which is how a
dealer looks at a felt, and it reads the same on the one open chair at a full
table, which is the case the label was actually for. The word is gone from all
ten, and the seat numbers underneath it are now the thing you see. What the word
carried for anyone reading by ear moves to an aria-label on the seat, which now
says more than the sighted version ever did: "Seat 3, empty", "Seat 1, Ann".

**The bar says what the table is.** It used to hide itself when nobody was
seated — silent at exactly the moment a dealer has never used this before. It
now says "Nobody's at the table yet." with one full-width button, Seat a player,
which opens the seat editor on the first free chair. That is the same panel
tapping a seat gives them, so the button teaches the gesture rather than
replacing it.

**Two dead controls stop pretending.** Resolve Pot and Next Hand are disabled
until somebody is at the table, which takes the accent green off the button that
cannot work and leaves the only green on screen on the one that can. Disabled
rather than hidden: hiding them would move the header the moment the first
player is named, and a control that vanishes teaches nothing about what it was
for. Next Hand's own explanatory title is kept and handed back when it
re-enables.

One bug of my own, found by walking the flow rather than by reading it: after
typing a name both header buttons stayed greyed out, still captioned "nobody is
at the table yet". commitSeatChange refreshes narrowly on purpose — a full
render on every keystroke takes the name field out from under the dealer
mid-word — and the comment directly above it already warns about this exact trap
for the action bar. The header needed the same line.

609 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes,
0 failures — the empty table is now one of those sizes, and had never been
audited before. Re-verified with the network cut: the first-run screen, the CTA
and seating a player all work offline.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps changed title from Hold'em dealer: works with no signal, and stops shouting to Hold'em dealer: offline, the empty table, and what it says out loud 2026-09-03 11:15:07 -06:00
jheaps added 1 commit 2026-09-03 11:56:01 -06:00
Looked at the felt at three times scale and the seats were falling off it. Not
sitting at a rail — hanging past the outer edge of the table into the page,
half a puck on green and half on nothing. Measured:

  1280x900, nine-handed   five of nine seats past the rail, worst 49px
  844x390,  six-handed    all six, worst 20px
  430x932,  six-handed    two, worst 12px
  360x640,  nine-handed   none

The ring sat at a flat 44% by 42% of the seat layer at every size, two numbers
picked against a phone. A fixed pair can only be right for one ratio of puck to
felt, and the felt's ratio changes with every viewport — so it was a different
amount of wrong on each one, which is exactly what makes something look like a
mistake rather than a choice.

The radius is derived now: the widest ring whose outermost seat still finishes
inside the table, worked out from the measured table, seat layer and puck. It
can only ever pull seats in — the old pair is kept as the ceiling — and it falls
back to that pair whenever there is nothing to measure, which is every call from
the unit tests and the first render before the felt is laid out.

Three things this turned up on the way, each caught by the sweep:

  * The seat layer is not centred on the table. Its insets differ top from
    bottom, so the first version — which assumed symmetry and worked from half
    widths — left a phone with 12px still over the rail. It works in absolute
    pixels against the nearer edge now.

  * The radius and the puck size depend on each other: the ring is limited by
    how big a puck is, the puck is sized by the `seatsTight` class, and that
    class was decided from the gaps between seats, measured at a radius that
    hadn't been worked out yet. Fixing the gap measurement then made a
    nine-handed phone overhang that had been fine, because the two were chasing
    each other. It is settled by trying the candidates in preference order
    instead: the roomy layout if everything fits, the tight one if not, and if
    neither fits, tight pucks on a ring widened only as far as it takes to stop
    seats colliding — because two seats fighting over a tap is a worse failure
    than a puck over the rail. A dealer can see an overhang; they cannot see
    that the seat they pressed wasn't the one that answered.

  * The spacing threshold was a flat 84px, the figure a 96px puck was judged
    against. Asking a 68px tight puck to keep a 96px puck's distance is a test
    it can never pass, so every landscape table fell through to the fallback and
    took an overhang it didn't need. It is a ratio now, not a number.

Result: worst overhang anywhere goes 49px to 9px, and 59 of 65 configurations
are completely clean. Desktop, tablet and every phone held upright: nothing over
the rail at all.

Residual, reported rather than tuned away: six configurations still overhang by
1-9px, all of them landscape at 640x360 and 667x375, where the felt is about
110-125px tall and no ring puts readable pucks inside that. The audit check is
left strict so they stay visible.

uiAudit gains "seats and chips stay on the table", which runs at every viewport
in the sweep. Fault-injected: putting the flat 44/42 back reproduces the original
failure exactly, including seat 3 at 49px.

Faster, too, because the measuring got hoisted out of the per-seat loops while I
was in there:

  renderSeats   7.3ms -> 1.8ms
  renderAll    12.4ms -> 6.8ms

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes.
Offline re-verified: a whole hand with the network cut, $800 conserved.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps changed title from Hold'em dealer: offline, the empty table, and what it says out loud to Hold'em dealer: a table that fits its game, offline, and a quieter first screen 2026-09-03 11:56:32 -06:00
jheaps added 1 commit 2026-09-03 12:20:13 -06:00
Opened the pot wizard — the one dialog in this tool that divides money — and its
Confirm button was below a scroll. Not on a tall table, not on one screen: at
every size I measured, with six players and a side pot.

  iPhone 14 390x844   244px of the dialog below the fold
  Galaxy S8 360x640   417px
  844x390 landscape   583px
  desktop 1280x900    172px

Nothing on screen said there was anything under the list of names, so a dealer
with the table waiting had to scroll a list of players to find the only control
that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious
answer and it cannot work here: the actions were the last child of the
scrolling content, so their containing block ended exactly where they did and
`bottom` had no room to lift them. Sticky on the *header* did work, for the
mirror-image reason — its container extends below it — which is what made the
failure confusing to read. Capping the player list wasn't enough either; the
pot breakdown and the log still overflowed on a short screen.

So the dialog is a proper column now: a title that doesn't move, a body that
scrolls, and a footer outside the scroll. potWizard.js appends its buttons to
the footer instead of into the body, and clears both together so a step can't
inherit the previous step's buttons. Measured after: Confirm on screen and
hit-testable at all six configurations, twelve-handed on a Galaxy S8 included,
and the dialog itself no longer scrolls at all.

The checkboxes are painted now, too. `accent-color` only ever coloured the tick
— the box around it stayed the platform's own bright white square, which on a
near-black dialog was the loudest thing on screen and the last raw control in a
tool where everything else had been drawn. Same surface ramp, same corner
radius, and a tick drawn with clip-path so it can't pick up a substituted font.

uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather
than trusting geometry — a button can be inside the viewport and still be under
something. Fault-injected: putting the footer back inside the scrolling body
reports Confirm 105px below the fold.

Two things checked and left alone, because they turned out to be right: the
standings table hides three columns on narrow screens through a container query
keyed to the sidebar rather than the viewport, which is why a desktop shows
fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/
Net read $0 for hands settled outside the wizard, which the comment above them
already explains.

Caught by my own guard on the way past: the token test read `scroll-padding` as
`padding` and failed the spacing scale. Correct by its rules, wrong in substance
— those values are a sticky bar's measured height, not a step in the rhythm — so
the lookbehind now rejects a hyphen as well as a word character. Re-fault-
injected to confirm it still catches a real ad-hoc gap.

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and
35 across the five overlays. The whole wizard walked end to end: side pot
resolved, hand logged, $1,560 on the table before and after.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps changed title from Hold'em dealer: a table that fits its game, offline, and a quieter first screen to Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen 2026-09-03 12:20:42 -06:00
jheaps merged commit 190ccebf6c into master 2026-09-03 12:24:00 -06:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jheaps/JoshHeaps.Net#117