Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal #120

Merged
jheaps merged 10 commits from polish/holdem-game-night-ready into master 2026-09-09 14:23:50 -06:00
Owner

Three rounds of work, and the last one is not about how the tool looks. It is about the tool being there when nine people are waiting and the phone is the only thing keeping the count.

The showdown never asked for the hands

Turn the card setting on and every street opens a sheet for the cards coming off the deck. That half always worked — on a phone, on a desk, in a normal hand, and in an all-in run-out where the board is dealt with no betting left.

The other half never asked at all. A dealer who entered all five community cards and pressed Showdown — resolve pot got a wizard that said:

Christopher, Mike from work, Alexandra and Bob haven't turned over, so who won is still yours to say.

with nothing anywhere in that sheet to turn anybody over with. The only route in was to close the wizard, tap a seat puck, find the two card slots in the seat editor, do it four times, and re-open the wizard — with the pot on the table and four people waiting.

Now the showdown button asks, exactly the way the street button does. The sheet opens on whoever the bar has just named as showing first, takes their two cards, and moves round the table. Four sheets later the wizard reads Chris wins $40 with three of a kind, nines and its button says Pay Chris.

Three rules hold the list together, and each is a test: only when the dealer asked to be asked; only the hands it doesn't already have; and in the order the cards actually come over. A hand won by folding asks nothing.

Closing the sheet stops the asking rather than skipping a player — and it isn't a dead end. The bar still says Showdown, and pressing it again resumes at the player the dealer stopped on.

The setting also lost its jargon. "Track the board and read the showdown" was the feature's name rather than what turning it on does to your evening; it now reads Ask me for the cards as they come out.

One green, and it is the thing to do next

The accent is a promise: this is the step. Measured over a whole hand, there were two accent-coloured controls at thirteen of seventeen steps — the bar's Call and the header's Resolve Pot, painting the identical gradient at opposite ends of a phone. At the showdown the most consequential tap in the hand was blue-grey while the corner shortcut was green.

The accent moves to .actionAdvance, which is the next step by definition, and comes off the header shortcut, which never is. Twenty-two measured steps of a real hand now show exactly one.

The offline promise depended on how you spelled the URL

This is the one that would have cost somebody a night.

Razor Pages routing is case-insensitive, so /Casino/TexasHoldem serves the identical page as /casino/texasholdem. A service worker's scope is not — the spec matches it as a plain string prefix. Measured, with the network cut:

registration controlling cached offline
/casino/texasholdem created yes 25 entries loads
/Casino/TexasHoldem created, active no nothing at all ERR_INTERNET_DISCONNECTED

The empty cache is the part that matters. The registration succeeded. The worker activated. Nothing was logged. But the precache list was handed to navigator.serviceWorker.ready, which resolves with the registration controlling this page — so on an out-of-scope page it never resolves, the message is never sent, and not one file is stored. A dealer who opened that URL to "load the tool for tonight" had loaded nothing, and no way to find out until the signal went.

Every in-site link is already lowercase, so the ordinary path was never at risk. A bookmark, a typed URL or a shared link was.

So one spelling wins: a 301 in Program.cs, scoped to /casino rather than applied site-wide — every file under wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path there cannot miss a real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the site, and /chessHub would break.

The handshake was fixed rather than left to be saved by the redirect: it now takes the worker off the registration itself and waits for activated on that.

Measured after: /Casino/TexasHoldem lands on the canonical URL, the page is controlled, 25 entries cached. Offline, the capital-C spelling opens the full tool with the hand in progress intact.

The icons the document never mentioned

The shell list is read off the DOM so it can't drift from the markup. The icons are the one part of the shell the DOM doesn't mention — the browser reads them out of the manifest — so the sweep missed all four, and an installed app launched with no signal had no icon to show for itself.

They're read out of the manifest now, not typed into a list here. iOS is the exception: it reads none of the manifest's icons and only <link rel="apple-touch-icon">, which is in the document, so the link sweep now takes stylesheets, icons and apple-touch together.

Reading the manifest is deliberately cache-first, which is backwards from how the rest of this worker thinks: a plain fetch for it failed on every offline launch and logged ERR_INTERNET_DISCONNECTED — noise on the one screen where a real error has to be visible. Offline console errors: zero.

The screen stays on while a hand is live

A dealer's phone lies face-up on the felt and dims, then locks, between the flop and the turn. Every hand, all night. None of the work above survives a black screen and a passcode with four people waiting to know whether the bet is called.

The risk runs entirely the other way, and that is what the tests are about. A lock that is taken and not dropped burns the battery through a break and a re-buy with nothing on screen to say why. So it is held only while betting is actually open, and:

  • a whole street of renders takes exactly one — sync() runs off renderAll, which fires many times a second;
  • two syncs in the same tick take exactly one, or the second sentinel leaks and nothing can release it;
  • a hand that ends inside the request leaves nothing held — a fold can end the hand between asking for a lock and being handed one;
  • the browser taking it back leaves us able to take it again. Not an edge case: the browser releases the lock itself whenever the document stops being visible and never gives it back.

Said once, at the moment it first becomes true, with the way out attached — a toast reading "Keeping the screen on while a hand is live" and a Turn off button. The alternative was a permanent header indicator, and the header is the part of this page most fought over for crowding. After that it is a setting, and settings live in the sidebar.

Asking the browser to keep the night

"The night lives in this browser and nowhere else" was always true and always missing its second half. localStorage is evictable — a browser may clear site data when the device runs short, and Safari deletes script-created storage after seven days without a visit. A table saved at last month's game can simply not be there at this one.

navigator.storage.persist() is the way to say this data is not that, and every browser wants the request to come from something the dealer did — so it fires on Post blinds & deal. Save & reset now says which of the two situations the device is in, and offers the ask as a button when it isn't the good one.

Checks

  • 885 unit tests pass (867 before; 18 new in wakeLock.test.js).
  • Five fault injections into the wake lock — the pending guard, the late-arrival release, the browser's own release, the between-hands rule, and the migration default — each fail the tests that cover them, and the files restore clean.
  • One test was passing for the wrong reason and is fixed: globalThis.navigator = fake does nothing, because Node has defined navigator as a getter since v21. Object.defineProperty installs it properly.
  • Walked by hand in a real browser at 412×915: deal → lock taken and announced; Turn off → dropped, toggle unchecked, setting saved; toggle back on mid-hand → retaken; fold to a winner → held through Award pot; Start Next Hand → released. Browser releasing the lock then a visibilitychange → retaken.
  • Redirect verified for /Casino/TexasHoldem, /CASINO, /Casino/TexasHoldem/QuickSplit and a query string, in exactly one hop; /Chess and / untouched.

Two pixels, and every seat on the felt paid for them

The layout sweep had carried the same residual for several rounds: on the two smallest phones with twelve players, seats painting over each other's names and stacks. It read like a geometry problem, and the layout code had already been round that loop twice — deriving the ring from the felt, searching for a puck width that clears.

It was a wrapped label.

ALL IN is the only thing that ever lands on a seat's stack line with a space in it. A stack figure has no spaces, so it can only overflow, and the puckAmountLong/puckAmountHuge sizes exist to stop it. Nothing had ever asked what the line does when it can break. Measured at 360×640 with twelve players all-in: it wanted 36px and had 34.

That is not a typography complaint, which is why it survived — it looks fine. The puck went from 55px tall to 66, and puck height is precisely what the ring is short of. At the ends of an ellipse consecutive seats are separated vertically, so a puck 11px taller is a puck 11px further into its neighbour. The layout search then did exactly what it is written to do: tried every width it has, found none that cleared, and fell back to the narrowest — which, being the narrowest, is also the one that wraps hardest.

So the fix is the rule every design system already gives a badge: one line, always. Not wrapping is only half an answer, though — a line that cannot wrap and still does not fit paints over the seat next door instead. Of the three things separating ALL IN from a grey money figure (a warm colour, a bold weight, wider letters), the letter-spacing is the one that costs pixels: 3.6px of the 36. On the tight tier it goes; the other two stay. Measured after: 33px of ink in a 34px box, and the full spacing on every puck with room for it.

And the bar was standing on the felt while nobody could act

Halving the overlap did not clear it, and the rest of the shortage was not on the felt at all. The sweep had been saying so for rounds, in a warning rather than a failure: at 360×640 with twelve players all-in the action bar was 267px of a 640px window — 42%, against its own "persistent chrome under 30%".

Two things in it were being shown at a moment when they could not be used.

The side-pot list is decision support. A player working out whether to call is owed the side pot they would be calling into, and that is worth three rows of a fixed bar. Once everyone is all-in there is no decision left to support — the only thing that happens for the rest of the hand is the dealer turning cards. During a run-out it now collapses to the main pot, which is the number a dealer says out loud, plus one line for everything else.

The status row said the player count twice in the same breath: a 12 players chip, then All-in — running out the board (12 players) immediately to its right. That reads like two different figures being quoted, and on a 360px phone it cost the sentence a second line.

before after
Bar, Galaxy S8 267px (42%) 249px (39%)
Bar, iPhone SE 267px (40%) 227px (34%)
Bar, Pixel 7 267px (32%) 227px (25%)
Felt height, iPhone SE 243px 283px
Worst seat overlap, iPhone SE 34px 4px
Worst seat overlap, Galaxy S8 34px 17px

Across the whole sweep: overlap offender lines 33 → 9, chips behind seats 30 → 5, failing audits 6 → 5, the iPhone SE's seat-text overlap gone entirely, and no new failures anywhere.

What is left, and why it is a decision rather than a fix

315 of 320 audits pass. The five that don't are one root cause on the two smallest screens: twelve players on a 360×640 phone in the two states that make the bar tallest, plus the iPhone SE's all-in table on the mouse pass. There the arithmetic is simply short — the ring has about 44px of rim per seat and a puck's footprint is 60. No width in the search clears that, which is why resolveSeatLayout has a documented fallback rather than pretending.

Closing the last five is a design decision, not another measurement: drop the bet chips at extreme crowding the way the tight tier already drops the hole cards, cap the seat count by viewport, or let the felt scroll. Each trades something a dealer can see, so it is written down in tests/holdem/README.md rather than picked by whoever is next holding a ruler.

894 tests pass (885 before; 9 new). Seven fault injections — three into the stylesheet invariants, four into the summary row — are each caught, and every file restores clean. The summary row is held to the rule that matters most for anything that hides a number: it must not lose a chip, checked at every table size from three players to twelve.

🤖 Generated with Claude Code

https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb

Three rounds of work, and the last one is not about how the tool looks. It is about the tool being there when nine people are waiting and the phone is the only thing keeping the count. ## The showdown never asked for the hands Turn the card setting on and every street opens a sheet for the cards coming off the deck. That half always worked — on a phone, on a desk, in a normal hand, and in an all-in run-out where the board is dealt with no betting left. The other half never asked at all. A dealer who entered all five community cards and pressed **Showdown — resolve pot** got a wizard that said: > Christopher, Mike from work, Alexandra and Bob haven't turned over, so who won is still yours to say. with nothing anywhere in that sheet to turn anybody over with. The only route in was to close the wizard, tap a seat puck, find the two card slots in the seat editor, do it four times, and re-open the wizard — with the pot on the table and four people waiting. Now the showdown button asks, exactly the way the street button does. The sheet opens on whoever the bar has just named as showing first, takes their two cards, and moves round the table. Four sheets later the wizard reads **Chris wins $40 with three of a kind, nines** and its button says **Pay Chris**. Three rules hold the list together, and each is a test: only when the dealer asked to be asked; only the hands it doesn't already have; and in the order the cards actually come over. A hand won by folding asks nothing. Closing the sheet stops the asking rather than skipping a player — and it isn't a dead end. The bar still says Showdown, and pressing it again resumes at the player the dealer stopped on. The setting also lost its jargon. "Track the board and read the showdown" was the feature's name rather than what turning it on does to your evening; it now reads **Ask me for the cards as they come out**. ## One green, and it is the thing to do next The accent is a promise: *this is the step*. Measured over a whole hand, there were two accent-coloured controls at thirteen of seventeen steps — the bar's **Call** and the header's **Resolve Pot**, painting the identical gradient at opposite ends of a phone. At the showdown the most consequential tap in the hand was blue-grey while the corner shortcut was green. The accent moves to `.actionAdvance`, which is the next step by definition, and comes off the header shortcut, which never is. Twenty-two measured steps of a real hand now show exactly one. ## The offline promise depended on how you spelled the URL This is the one that would have cost somebody a night. Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical page as `/casino/texasholdem`. A service worker's scope is not — the spec matches it as a plain string prefix. Measured, with the network cut: | | registration | controlling | cached | offline | |---|---|---|---|---| | `/casino/texasholdem` | created | yes | 25 entries | loads | | `/Casino/TexasHoldem` | created, active | **no** | **nothing at all** | `ERR_INTERNET_DISCONNECTED` | The empty cache is the part that matters. The registration succeeded. The worker activated. Nothing was logged. But the precache list was handed to `navigator.serviceWorker.ready`, which resolves with the registration *controlling this page* — so on an out-of-scope page it never resolves, the message is never sent, and not one file is stored. A dealer who opened that URL to "load the tool for tonight" had loaded nothing, and no way to find out until the signal went. Every in-site link is already lowercase, so the ordinary path was never at risk. A bookmark, a typed URL or a shared link was. So one spelling wins: a 301 in `Program.cs`, scoped to `/casino` rather than applied site-wide — every file under `wwwroot/casino` and `wwwroot/images/casino` is already lowercase, so lowercasing a path there cannot miss a real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the site, and `/chessHub` would break. The handshake was fixed rather than left to be saved by the redirect: it now takes the worker off the registration itself and waits for `activated` on that. **Measured after:** `/Casino/TexasHoldem` lands on the canonical URL, the page is controlled, 25 entries cached. Offline, the capital-C spelling opens the full tool with the hand in progress intact. ## The icons the document never mentioned The shell list is read off the DOM so it can't drift from the markup. The icons are the one part of the shell the DOM doesn't mention — the browser reads them out of the manifest — so the sweep missed all four, and an installed app launched with no signal had no icon to show for itself. They're read out of the manifest now, not typed into a list here. iOS is the exception: it reads none of the manifest's icons and only `<link rel="apple-touch-icon">`, which *is* in the document, so the link sweep now takes stylesheets, icons and apple-touch together. Reading the manifest is deliberately cache-first, which is backwards from how the rest of this worker thinks: a plain `fetch` for it failed on every offline launch and logged `ERR_INTERNET_DISCONNECTED` — noise on the one screen where a real error has to be visible. Offline console errors: **zero**. ## The screen stays on while a hand is live A dealer's phone lies face-up on the felt and dims, then locks, between the flop and the turn. Every hand, all night. None of the work above survives a black screen and a passcode with four people waiting to know whether the bet is called. The risk runs entirely the other way, and that is what the tests are about. A lock that is taken and not dropped burns the battery through a break and a re-buy with nothing on screen to say why. So it is held only while betting is actually open, and: - a whole street of renders takes exactly one — `sync()` runs off `renderAll`, which fires many times a second; - two syncs in the same tick take exactly one, or the second sentinel leaks and nothing can release it; - a hand that ends inside the request leaves nothing held — a fold can end the hand between asking for a lock and being handed one; - the browser taking it back leaves us able to take it again. Not an edge case: the browser releases the lock itself whenever the document stops being visible and never gives it back. Said once, at the moment it first becomes true, with the way out attached — a toast reading *"Keeping the screen on while a hand is live"* and a **Turn off** button. The alternative was a permanent header indicator, and the header is the part of this page most fought over for crowding. After that it is a setting, and settings live in the sidebar. ## Asking the browser to keep the night "The night lives in this browser and nowhere else" was always true and always missing its second half. `localStorage` is evictable — a browser may clear site data when the device runs short, and Safari deletes script-created storage after seven days without a visit. A table saved at last month's game can simply not be there at this one. `navigator.storage.persist()` is the way to say this data is not that, and every browser wants the request to come from something the dealer did — so it fires on **Post blinds & deal**. Save & reset now says which of the two situations the device is in, and offers the ask as a button when it isn't the good one. ## Checks - **885 unit tests pass** (867 before; 18 new in `wakeLock.test.js`). - Five fault injections into the wake lock — the `pending` guard, the late-arrival release, the browser's own release, the between-hands rule, and the migration default — each fail the tests that cover them, and the files restore clean. - One test was passing for the wrong reason and is fixed: `globalThis.navigator = fake` does nothing, because Node has defined `navigator` as a getter since v21. `Object.defineProperty` installs it properly. - Walked by hand in a real browser at 412×915: deal → lock taken and announced; **Turn off** → dropped, toggle unchecked, setting saved; toggle back on mid-hand → retaken; fold to a winner → held through **Award pot**; **Start Next Hand** → released. Browser releasing the lock then a `visibilitychange` → retaken. - Redirect verified for `/Casino/TexasHoldem`, `/CASINO`, `/Casino/TexasHoldem/QuickSplit` and a query string, in exactly one hop; `/Chess` and `/` untouched. ## Two pixels, and every seat on the felt paid for them The layout sweep had carried the same residual for several rounds: on the two smallest phones with twelve players, seats painting over each other's names and stacks. It read like a geometry problem, and the layout code had already been round that loop twice — deriving the ring from the felt, searching for a puck width that clears. It was a wrapped label. `ALL IN` is the only thing that ever lands on a seat's stack line with a space in it. A stack figure has no spaces, so it can only overflow, and the `puckAmountLong`/`puckAmountHuge` sizes exist to stop it. Nothing had ever asked what the line does when it *can* break. Measured at 360×640 with twelve players all-in: **it wanted 36px and had 34.** That is not a typography complaint, which is why it survived — it looks fine. The puck went from **55px tall to 66**, and puck height is precisely what the ring is short of. At the ends of an ellipse consecutive seats are separated *vertically*, so a puck 11px taller is a puck 11px further into its neighbour. The layout search then did exactly what it is written to do: tried every width it has, found none that cleared, and fell back to the narrowest — which, being the narrowest, is also the one that wraps hardest. So the fix is the rule every design system already gives a badge: one line, always. Not wrapping is only half an answer, though — a line that cannot wrap and still does not fit paints over the seat next door instead. Of the three things separating `ALL IN` from a grey money figure (a warm colour, a bold weight, wider letters), the letter-spacing is the one that costs pixels: 3.6px of the 36. On the tight tier it goes; the other two stay. Measured after: **33px of ink in a 34px box**, and the full spacing on every puck with room for it. ## And the bar was standing on the felt while nobody could act Halving the overlap did not clear it, and the rest of the shortage was not on the felt at all. The sweep had been saying so for rounds, in a warning rather than a failure: at 360×640 with twelve players all-in the action bar was **267px of a 640px window — 42%**, against its own "persistent chrome under 30%". Two things in it were being shown at a moment when they could not be used. The **side-pot list** is decision support. A player working out whether to call is owed the side pot they would be calling into, and that is worth three rows of a fixed bar. Once everyone is all-in there is no decision left to support — the only thing that happens for the rest of the hand is the dealer turning cards. During a run-out it now collapses to the main pot, which is the number a dealer says out loud, plus one line for everything else. The **status row** said the player count twice in the same breath: a `12 players` chip, then `All-in — running out the board (12 players)` immediately to its right. That reads like two different figures being quoted, and on a 360px phone it cost the sentence a second line. | | before | after | |---|---|---| | Bar, Galaxy S8 | 267px (42%) | 249px (39%) | | Bar, iPhone SE | 267px (40%) | 227px (34%) | | Bar, Pixel 7 | 267px (32%) | 227px (25%) | | Felt height, iPhone SE | 243px | 283px | | Worst seat overlap, iPhone SE | 34px | 4px | | Worst seat overlap, Galaxy S8 | 34px | 17px | Across the whole sweep: **overlap offender lines 33 → 9, chips behind seats 30 → 5, failing audits 6 → 5**, the iPhone SE's seat-text overlap gone entirely, and no new failures anywhere. ## What is left, and why it is a decision rather than a fix **315 of 320 audits pass.** The five that don't are one root cause on the two smallest screens: twelve players on a 360×640 phone in the two states that make the bar tallest, plus the iPhone SE's all-in table on the mouse pass. There the arithmetic is simply short — the ring has about 44px of rim per seat and a puck's footprint is 60. No width in the search clears that, which is why `resolveSeatLayout` has a documented fallback rather than pretending. Closing the last five is a design decision, not another measurement: drop the bet chips at extreme crowding the way the tight tier already drops the hole cards, cap the seat count by viewport, or let the felt scroll. Each trades something a dealer can see, so it is written down in `tests/holdem/README.md` rather than picked by whoever is next holding a ruler. **894 tests pass** (885 before; 9 new). Seven fault injections — three into the stylesheet invariants, four into the summary row — are each caught, and every file restores clean. The summary row is held to the rule that matters most for anything that hides a number: it must not lose a chip, checked at every table size from three players to twelve. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 3 commits 2026-09-09 04:32:13 -06:00
The green is a promise: this is the step. Measured over a whole hand — seventeen
steps — it was on two controls at thirteen of them, the bar's Call and the header's
Resolve Pot painting the identical gradient at opposite ends of a phone, and on the
header alone at the other four while the bar held the only button there was to press.
At showdown the most consequential tap in the hand, "Showdown — resolve pot", was
blue-grey while the corner shortcut was green.

So the accent moves to .actionAdvance, which is always the next step by definition,
and comes off the header shortcut, which never is. Twenty-two measured steps of a
real hand now show exactly one.

The sweep checks it: oneNextStep() counts the accent-coloured controls that are
actually on screen, scoped to an open sheet when there is one. Six broken versions
were run at it, and the two that took three goes to catch were both the same mistake
in the injection rather than a hole in the check — a button painted green with the
panel shut isn't rendered, and one painted green below the fold is deliberately
ignored. Scrolled into view it is named alongside the bar's Call, and goes clean
again when the green comes off.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
The card feature had two halves and only one of them ever asked a question. Turn the
setting on and every street opens a sheet for the cards coming off the deck — that
worked, on a phone and on a desk, in a normal hand and in an all-in run-out. The
other half never asked at all. A dealer who entered all five community cards, pressed
"Showdown — resolve pot", and got a wizard saying

    Christopher, Mike from work, Alexandra and Bob haven't turned over, so who
    won is still yours to say.

with nothing in that sheet to turn anybody over with. The only route in was to close
the wizard, tap a seat puck, find the two card slots in the seat editor, do it four
times and re-open the wizard, with the pot on the table and four people waiting. The
one moment the tool is certain to know the hands is the moment it stopped asking.

So the showdown button asks. The sheet opens on whoever the bar has just named as
showing first, takes their two cards, and moves round the table from there; hands
already entered are skipped, because a hand entered is a question already answered.
Four sheets later the wizard opens reading "Chris wins $40 with three of a kind,
nines" and its button says "Pay Chris" instead of "Confirm Winner(s)".

Leaving the sheet stops the asking rather than skipping a player: closing it says the
tool has the wrong end of the stick, and the answer to that is to get out of the way.
It isn't a dead end — the wizard still names whoever didn't turn over, the bar still
says "Showdown — resolve pot", and pressing it again resumes at the player the dealer
stopped on, because the list is read off the cards on the table rather than remembered.

CardPicker.close() takes a reason so the chain can tell "that sheet filled its last
slot" from "the dealer left". Two callers pass nothing and don't care.

The setting kept its behaviour and lost its jargon: "Track the board and read the
showdown" was the feature's name rather than what turning it on does to your evening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
Three failures that only ever happen at a table, none of which show up on a
desk with a good connection and a plugged-in laptop.

The offline promise depended on how the URL was spelled. Razor Pages routing
is case-insensitive, so /Casino/TexasHoldem serves the identical page; a
service worker's scope is matched as a plain string prefix, so a worker scoped
to /casino/ does not control it. The registration succeeded, the worker
activated, nothing was logged, and the cache stayed completely empty --
because the precache list was handed to navigator.serviceWorker.ready, which
resolves with the registration controlling the page and so never resolves at
all outside its scope. A dealer who opened that URL to load the tool for the
night had loaded nothing.

One spelling wins now, via a 301 scoped to /casino rather than applied
site-wide: every file under wwwroot/casino and wwwroot/images/casino is
already lowercase, so lowercasing there cannot miss a real file on a
case-sensitive filesystem. That guarantee does not hold for the rest of the
site, and /chessHub would break. The handshake is fixed rather than left to be
saved by the redirect -- it takes the worker off the registration itself.

The manifest icons were never cached, because the shell list is read off the
DOM and the DOM never mentions them. They are read out of the manifest now,
cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and
the link sweep picks up apple-touch-icon, which is the only one iOS reads.

The screen slept mid-hand. A wake lock is held while betting is open and
dropped the moment it is not: through a whole street of renders it takes
exactly one, two syncs in a tick take exactly one, a hand that ends inside the
request leaves nothing held, and the browser taking it back leaves us able to
take it again. Announced once with a Turn off button rather than a permanent
header indicator; a setting after that.

And localStorage is evictable, which the Save & reset panel now says out loud.
persist() is asked for on Post blinds & deal, because every browser wants that
request to come from something the dealer did.

885 tests pass, 18 of them new. Five fault injections into the wake lock are
each caught. One of the new tests was green for the wrong reason first:
globalThis.navigator = fake does nothing, because Node has defined navigator
as a getter since v21.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 05:02:04 -06:00
The sweep had carried the same residual for rounds: on the two smallest
phones with twelve players, seats painting over each other's names and
stacks. It read like a geometry problem. It was a wrapped label.

"ALL IN" is the only thing that ever lands on the stack line with a space in
it. A stack figure has no spaces, so it can only overflow, and the
puckAmountLong/Huge sizes exist to stop that; nothing had asked what the line
does when it can break. Measured at 360x640 with twelve players all-in, it
wanted 36px and had 34, broke onto a second line, and an 11px line became 22.

That is not a typography complaint, which is why it survived: it looks fine.
The puck went from 55px tall to 66, and puck height is what the ring is short
of -- at the ends of an ellipse consecutive seats are separated vertically, so
a puck 11px taller is a puck 11px further into its neighbour. The layout
search then did exactly what it is written to do: tried every width it has,
found none that cleared, and fell back to the narrowest, which is also the one
that wraps hardest.

So the rule every design system gives a badge applies here too: one line,
always. Not wrapping is only half of it -- a line that cannot wrap and still
does not fit paints over the seat next door instead -- so on the tight tier
the mark gives up its letter-spacing, 3.6px of the 36 it wanted, and keeps the
warm colour and the bold weight that do the same job.

Halving the overlap did not clear it, and the rest of the shortage was not on
the felt. The sweep had been saying so in a warning rather than a failure: at
360x640 the bar was 267px of a 640px window, 42%, against its own "persistent
chrome under 30%". Two things in it were being shown when they could not be
used. The side-pot list is decision support, and during a run-out there is no
decision to support -- it collapses to the main pot and one line for the rest.
The status row said the player count twice in the same breath, as a chip and
again inside the run-out sentence beside it.

Measured: bar 267 -> 249 on a Galaxy S8 and 267 -> 227 on an iPhone SE, felt
243 -> 283 there, worst seat overlap 34px -> 4px. Across the sweep, overlap
offender lines 33 -> 9, chips behind seats 30 -> 5, failing audits 6 -> 5, and
the iPhone SE's seat-text overlap gone entirely. No new failures.

894 tests pass, 9 of them new. Seven fault injections are each caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 05:03:42 -06:00
315 of 320 audits pass. The five that do not are one root cause on the two
smallest screens: twelve seats want about 60px of footprint each and the ring
has 44px of rim to give them, in the two states that make the bar tallest.

Written down rather than left to be rediscovered, because closing it is a
design decision -- drop the bet chips at extreme crowding the way the tight
tier already drops the hole cards, cap the seat count by viewport, or let the
felt scroll -- and each trades something a dealer can see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 05:21:15 -06:00
The stylesheet had honoured prefers-reduced-motion for a while, in the three
places it mattered least: a 2px press-down on a button, a checkbox tick, and
the acting seat's pulse. What none of them covered was motion that moves
something across the screen, which for anyone with a vestibular disorder is
the part that matters. A button dipping under a thumb is not what makes people
ill.

Four things were doing it, and the three largest were in JavaScript where a
stylesheet cannot reach: the seat editor sheet, which slides the full height
of itself up from off-screen every time a dealer taps a chair; the toast; and
three scrollIntoView({ behavior: "smooth" }) calls, the most provocative of
the lot because what moves is the whole viewport rather than one control.

The scrolls go through Render.bringIntoView now, which reads the setting per
call rather than at page load -- a dealer who turns it on mid-session should
be taken seriously without reloading a table they cannot afford to reload. The
toast keeps its fade and loses its travel, because something that appears
already in place reads as a rendering glitch rather than as news. The sheet's
visibility delay flips at once, or it would leave a sheet that has already
jumped away still painted for a quarter of a second.

The press-down block held nine :active selectors and looked deliberate. It was
written to stop a stuck press state when a tap turns into a scroll, and had
reached for the reduced-motion query as the nearest proxy -- so it covered
.dealerChip and .raiseQuickBtn while missing .actionBtn, the class on Fold,
Call, Raise and Post blinds & deal. Read as its own rule the membership is not
a judgement call: every :hover and :active transform in the file belongs
there. There are sixteen. Nine were listed.

The scan that found the other seven took two goes. A line-by-line version
walked straight past a deliberately broken build, because the injected rule
was written on one line and neither "opens a rule" nor "is a transform"
matched. Parsing blocks caught it -- and then failed on the clean file, which
is how .toastAction, .toastClose and .potWizardChoice came to light: a
multi-line group had been truncated to its last line, so nothing had ever
looked at them.

910 tests pass, 16 of them new. Six script and stylesheet fault injections are
each caught, and the reduced/no-preference split is verified in a real browser
against both context settings.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 05:46:29 -06:00
A focused <input type="number"> is a spinbutton, and a browser lets the mouse
wheel drive it. Measured on the running page at 1280x900 with the sidebar
open: focus the small blind and scroll, $5 becomes $5.50. Focus a seat's stack
and scroll, $500 becomes $501. Both go straight through to localStorage,
because these fields save on `input` like everything else here.

Nothing is announced and nothing looks wrong. The tool has invented a chip, on
a table whose whole purpose is that the chip count is right, and the gesture
that did it was a dealer reaching for another panel.

The rule is narrow on purpose. Every other way of driving a spinbutton is
deliberate -- the arrow keys, the stepper arrows, typing -- so scrolling is
the only one taken away. Verified after: the value holds, one ArrowUp still
moves it, typing still works, and the page still scrolls.

Blur rather than preventDefault. Cancelling the event stops the value moving
and stops the page moving with it, which trades a silent bug for a sidebar
that mysteriously will not scroll -- a problem the dealer can see and cannot
explain, instead of one they cannot see at all.

The decision lives in Money as a predicate beside selectsOnFocus, so the rule
is testable without a browser and main.js only does the blurring.

Two things checked rather than assumed. The tagName guard in the predicate is
decoration: deleting it passes every test, because nothing but an <input>
carries type="number". That is now said in the comment instead of being left
to look load-bearing. And the page's one <select> -- which way the blinds move
-- is not vulnerable: focused, hovered and scrolled 300px in Chromium it stays
where it was, so it is left out of the rule rather than defended against.

916 tests pass, 6 of them new. Four fault injections into the predicate are
caught; a fifth was applied, not caught, and turned out not to be a fault.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 06:40:37 -06:00
The sweep has checked WCAG AA contrast for a long time and the page passed. That
result was worth less than it looked.

WCAG 2's ratio is a luminance gap, and a luminance gap overstates readability once
one colour is near black — which is every reading on a page whose ground is #141414.
Tuning a colour until it clears 4.5:1 there lands on text that passes the audit and
still reads as a smudge across a table in a dim room. Several rules in this
stylesheet said as much in their own comments, one of them "measured 4.5:1 at
#a5a5a5 exactly, so it sits above that": the right instinct through the wrong lens.

The check also could not see half the backgrounds it scored against. It reads
background-color and takes the best contrast of any candidate ancestor, so text on
the felt was scored against the dark page behind the felt. Two plain AA failures sat
in a clean report for as long as the check has existed — the chair number on the
brown rim at 2.65:1, and the pot's own label, gold on green, at 2.66:1.

runContrastAudit.js scores with APCA and gets the background by screenshotting the
page with its text made transparent and reading the pixels, which no gradient or
shadow can fool. The first version walked the DOM and called the gold dealer button
black-on-black at Lc 0.4; that false positive is why it works this way.

Found: 65 of 92 pieces of text below their level, 54 passing AA at the same moment,
worst Lc 28. Now nothing is below Lc 60, nothing fails AA, and the worst is Lc 60.4.

Three fixes worth naming, none of them "make the grey lighter":

- A player's stack was secondary text at Lc 44, under a name in --text at a heavier
  weight. That says a dealer needs the name more than the number; during a hand the
  opposite is true. Hierarchy comes from size and weight now, which is free, rather
  than from fading, which on a dark ground is the most expensive way to say it.
- An empty seat had no background of its own, so the felt decided how readable its
  number was: Lc 28 on the rim, 30 on the green, 33 further in. It has a ground now,
  at 0.62 and not the 0.82 first written — contrast is nearly flat across that range
  and the heavier wash put four dark rectangles on the felt like holes.
- The pot's label sat on the one green in the design. The disc is deeper rather than
  the label bleached, because gold on dark green is the pairing this page is built
  on. Deepening only its outer stop moved it two points: the highlight is at 32%/28%
  and that is exactly where the label sits.

apca.js is checked against the eight published reference pairs, png.js against
round-tripped fixtures for every filter. Four fault injections caught; a fifth
turned out to be a no-op — the Paeth predictor is symmetric in its first two
arguments over all 16,777,216 byte triples, so that swap is untestable, which is
written down rather than papered over with a fixture that appears to catch it.

Still below the strict APCA level: 55, all of them 10-12px labels that would need
white type or a bigger seat puck. Reported, not chased, and said out loud in the
sweep's own notes.

Layout sweep unchanged: 5 FAILs, 14 offender lines, no regressions. 950 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 07:29:17 -06:00
The seat sheet is the second-most-used surface here — every buy-in, every
rename, every correction goes through it — and it was the least finished one.
Seven things were stacked flat at one weight and all open at once. Measured on
a 390px phone it came to 594px and scrolled, and the three controls a dealer
touches on nearly every visit were 132px of that. The chip counter alone was
174px, the tallest single thing in the sheet, for a job most tables do once a
night or never; the two hole-card slots were 62px of dashed grey on every table
that never asked to be shown a card.

Cards, the contribution and the custom fields are behind a fold now, and which
of the three stand open is a question about the seat rather than a setting: a
seat holding cards opens its cards, a seat with chips counted opens its count,
a seat with a note on it opens its notes. A shut section says the answer on its
own row — the cards, the total, how many notes — so nothing already entered is
further away than it was. An empty chair is 419px and does not scroll; a seat
with everything filled in is 625 and opens all three.

The fields have the width the sheet always had. `.fieldRow input` caps at 140px,
which is right in a 320px sidebar of settings and wrong in a sheet holding a
whole phone: "Name" sat against the left edge with 203px of nothing between it
and a field a third of the row wide. Same 44px control, 300px instead of 140.

The chip counter's values are text. They were a `title` attribute, which is a
tooltip, which is nothing on the device this is used on — five identical number
boxes told apart by five coloured dots. It also left the wrapping <label> with
no text in it, so they had no accessible name either.

And the sheet has a scrim. It was the only one of the tool's seven overlays
without one, while covering the felt from 250px down and the action bar with
it — modal in everything but appearance. What stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb reaching for
a name field. It dismisses on a tap and on Escape now, like the other six; a
card picker opened from inside still takes Escape first. In landscape the scrim
stops where the sheet stops, so Fold, Call and Raise keep the rail.

It is put away with visibility rather than opacity, which is the same lesson the
sheet above it already learned about transforms — and the layout sweep caught it
the same way. At `opacity: 0` a fixed, full-window box is invisible and still
entirely there: bottomFurniture() read it as furniture standing 640px tall at
the bottom of a 640px phone, in every state and on every screen, and asked
scroll-padding to reserve the whole window for it. Ninety-eight scenarios failed
on it; hidden is hidden, and the sweep is back to the five it started with.

The toast steps over the sheet the way it already steps over the action bar.
Both are fixed to the bottom of a phone, and a "Keeping the screen on" toast
was landing squarely on the seat form with its Undo and its × live above the
fields. The height is measured and published, because it changes the moment a
section is unfolded.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps added 1 commit 2026-09-09 08:11:31 -06:00
The header stood 120px tall on a 390x844 phone and only 46 of that was a
control. The other 74 were a 22px page title with a gold rule under it, a back
link, and a gold "Hand #1" pill — three things read once, above a screen whose
whole subject is the green table underneath. Sixteen percent of the window,
spent before the felt started.

A phone held sideways had already solved this. The landscape block clips the
title and puts everything on one row, because at 390px tall there was no other
option. Upright never got the same answer, and upright is how this tool is
held. It is 44px now, the table starts at 78px instead of 152, and the felt
went from 496px to 570 — about fifteen percent more table on the same screen.

Nothing left the page:

- The title is still the document's <h1>, clipped rather than removed, so it is
  still the heading a screen reader lands on and still what the browser tab and
  the installed app are called.
- The back link keeps a 44px box and loses only the word "Casino", which is what
  every phone does with a back link. The name a screen reader gets is on the <a>
  either way, and it contains the visible word on the sizes that still show it.
- The hand number moved to the action bar's status row, in front of the street.
  That row already says what hand you are in — the street, the pot, the player
  count — and this is the fourth fact of the same kind; a counter read after the
  fact had no business holding a line of the header all night. It costs the bar
  nothing: the five audit scenarios that report the bar's height report the same
  249, 227, 203, 227 and 227 pixels as before.

The three buttons take the width the title gave up rather than shrink-wrapping
to the right of it, which is 26px of extra target each. `flex: 1 1 0` was wrong
here — it divides the row into equal thirds and squeezes "Next Hand ⟳", 105px of
text, into 99, and these are nowrap, so what gives is the label. An `auto` basis
makes the label the floor and the spare width the bonus. On the narrowest phone
in the sweep the arrow and all three buttons come to 323px of 336.

The arrow was 32px on the first pass and the layout sweep caught it at 31x43 on
every touch viewport there is — 56 scenarios, one control. The word had been
carrying the width; with the word gone the arrow is the whole target. 44 square.

The sweep is down from five failures to one. Four of the five were seats and bet
chips colliding on a 360px Galaxy S8, and 76px more felt was all they needed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps merged commit 6e7b047d3e into master 2026-09-09 14:23:50 -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#120