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".
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.
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
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 loud2026-09-03 11:15:07 -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 screen2026-09-03 11:56:32 -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 screen2026-09-03 12:20:42 -06:00
jheaps
merged commit 190ccebf6c into master2026-09-03 12:24:00 -06:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
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
bottomhad 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-coloronly 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
renderSeatsrenderAllTwo new permanent audit checks
Checked and deliberately left alone
$0for hands settled outside the wizard; the comment above them already explains it.ViewData["cssVersion"]renders empty on every page of the site. Inert —Program.cssendsCache-Control: no-storeon all static files.Bugs of my own, caught by walking the flows
commitSeatChangerefreshes narrowly on purpose, and the comment above it already warned about this exact trap.scroll-paddingaspaddingand 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.Validated
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_015bnp7xKUPFFSpwkYNFaPgbThe 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_015bnp7xKUPFFSpwkYNFaPgbHold'em dealer: works with no signal, and stops shoutingto Hold'em dealer: offline, the empty table, and what it says out loudLooked 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_015bnp7xKUPFFSpwkYNFaPgbHold'em dealer: offline, the empty table, and what it says out loudto Hold'em dealer: a table that fits its game, offline, and a quieter first screenHold'em dealer: a table that fits its game, offline, and a quieter first screento Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen