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
1056 lines
63 KiB
Markdown
1056 lines
63 KiB
Markdown
# Hold'em dealer tests
|
||
|
||
Unit tests for the money logic behind `/Casino/TexasHoldem` — the betting engine, the
|
||
side-pot calculator, the split calculator and the table state.
|
||
|
||
```bash
|
||
node --test tests/holdem/*.test.js
|
||
```
|
||
|
||
No install step, no `package.json`, no dependencies. The browser modules are plain
|
||
script-tag files that each end with a `module.exports` guard, so Node can require them
|
||
as-is; `load.js` puts them on `globalThis` under the same names they use to find each
|
||
other in the page, in the load order from `TexasHoldem.cshtml`.
|
||
|
||
## What's covered
|
||
|
||
| File | Covers |
|
||
| --- | --- |
|
||
| `potCalculator.test.js` | Main and side pots, dead money, uncalled bets, odd-cent distribution |
|
||
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
|
||
| `state.test.js` | Button rotation, which seats post blinds, session standings |
|
||
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
||
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
|
||
| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table |
|
||
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
|
||
| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) |
|
||
| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) |
|
||
| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) |
|
||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||
|
||
## The layout audit
|
||
|
||
The unit tests above cover the money. `uiAudit.js` covers the thing you can only see
|
||
in a browser: whether a dealer can actually reach every control.
|
||
|
||
```bash
|
||
node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||
```
|
||
|
||
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||
no way to scroll to it, that no grid column has collapsed to nothing, that no
|
||
text is painted outside its box, that no seat covers another seat's writing, and that
|
||
no bet chip is hidden behind a seat or another chip. It
|
||
runs over the table, the raise pad, all five overlays and Quick Split, at thirteen
|
||
viewports from a
|
||
360px phone to a 1600px desktop and the same phones on their side — each viewport
|
||
twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
|
||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||
which is a third of what is on the felt during a hand left unlooked at.
|
||
|
||
The raise pad is newer than the rest and was worth every second it costs. It is part
|
||
of the bar rather than an overlay, so nothing here had a step that opened it, and it is
|
||
both the tallest thing the bar ever holds and the only place in the tool a dealer types
|
||
an amount. The first sweep that opened it found a 36px-tall field on every phone in the
|
||
list: the `hover: none` rule that sizes fields for a thumb names the settings panels and
|
||
the seat form, and the one field on the one surface built for thumbs was not in the
|
||
list. It also found the rail squeezing its own status line under the pad, which is the
|
||
kind of thing that only happens in the state nobody was looking at.
|
||
|
||
"Covered" and "out of view" are told apart before anything is hit-tested. An element
|
||
clipped away by a panel that scrolls is not covered by whatever happens to be painted
|
||
at that height, and reporting it that way is a diagnosis of something that isn't
|
||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||
viewports running as "fully covered by button Confirm Winner(s)".
|
||
|
||
Text is measured where it was painted, not where its box is. The two are different
|
||
things and the difference is not academic: a name is centred, so its box is wider than
|
||
its ink, and a name kept clear of the dealer's chip by a float is out of the way on the
|
||
line beside the chip and full width on the line below. The check used to model that as
|
||
"box minus padding", which is one way of keeping text out of somewhere and not the only
|
||
one — it called every seat in the sweep an offender while the text sat exactly where it
|
||
was meant to. Range rects ask the question directly, so padding, floats, centring,
|
||
wrapping and a short label in a wide box all come out right without any of them being
|
||
modelled.
|
||
|
||
Offenders say how much is covered rather than that something is. "Is under a badge"
|
||
reads the same for a stack a dealer cannot read and for a badge's rounded corner
|
||
grazing the last digit, and the difference decides whether it is worth fixing. Probing
|
||
the whole line and counting turned the residual this sweep has carried for three rounds
|
||
into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context
|
||
only.
|
||
|
||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||
depends on how many people are sitting down far more than on anything a viewport can
|
||
report. A full table is where the seats crowd and it had never been looked at: the
|
||
first sweep that did found one player's name or stack painted under another on eighteen
|
||
of the configurations, on every phone in the list.
|
||
|
||
The third shape is the same five players with the board turned on and five cards on it.
|
||
It is deliberately the same roster in the same chairs as the second, so the only thing
|
||
that differs between the two rows is the board — a seventh player would have told us
|
||
that a seven-handed table crowds, which is true, already known, and not what that row
|
||
is there to find out. It earned its place immediately: the board takes the middle of
|
||
the felt, or a strip above it where the middle is too small, and the strip costs the
|
||
felt height. The seats follow a felt that changes size on their own, being placed in
|
||
percentages of it; the chips anchored inside them do not, being placed in pixels. They
|
||
were laid out for a felt 23px taller than the one they were painted on.
|
||
|
||
## What the sweep taught the layout
|
||
|
||
For a long run of rounds the seat ring was *predicted* onto the table: work out the
|
||
widest ring whose outermost puck still finishes inside the rail, place the seats, and
|
||
trust it. The prediction is correct arithmetic and it was wrong on the page anyway,
|
||
because every input to it moves after it is taken — the width search settles on a
|
||
narrower puck, a name wraps to a second line or gets shortened out of one, the board
|
||
appears in the middle. Each of those is up to 15px of puck height arriving after the
|
||
answer. Ninety-six of the ninety-eight offenders this sweep could find were that.
|
||
|
||
So render.js now finishes the way the audit judges it. After the seats are placed it
|
||
measures where they actually landed, and if the worst of them hangs 6px past the rail
|
||
the ring comes in by 6px and they are placed again — the same question this file asks,
|
||
asked by the code that has to get it right. Two passes, bounded, because each one is a
|
||
forced layout of the whole felt and it runs after every tap a dealer makes.
|
||
|
||
That is the general shape of the lesson here: a layout that predicts itself has to be
|
||
right about the future, and a layout that measures itself only has to be right about
|
||
the present.
|
||
|
||
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
|
||
eleven for a long run of rounds. Both were written and tested and simply never called.
|
||
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
|
||
where `hover: hover` says there is a pointer, and a headless desktop browser is a
|
||
pointer, so a single pass had never measured a touch target at any viewport. Between
|
||
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
|
||
button on Quick Split.
|
||
|
||
A check that decides it doesn't apply says so in the output rather than reporting no
|
||
offenders, which reads identically to a pass. The same goes for the two exemptions
|
||
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||
sentence, whose size is set by the line it sits in.
|
||
|
||
Deliberately not in CI: the unit suite is dependency-free and runs in about five
|
||
seconds, while this one needs a browser and a running site. Run it after any change that adds or
|
||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||
call `LayoutAudit.runAll()`.
|
||
|
||
The contrast check is built so it cannot report a false positive: backgrounds
|
||
composite through alpha, and where a gradient is involved every colour stop is tried
|
||
and the most flattering one used. A finding means the text fails even under the
|
||
reading most generous to it. Two earlier attempts at this got it wrong in both
|
||
directions — one read only `background-color` and scored text on a gradient against
|
||
nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
|
||
against known-answer cases before it is believed.
|
||
|
||
Touch targets are measured by probing outward with `elementFromPoint` rather than by
|
||
reading the element box, because the box lies in both directions: a small control can
|
||
be padded out with a transparent `::after` and respond well beyond its painted size,
|
||
and a checkbox inside a label is hit by tapping the label. The 44px rule is only
|
||
enforced where `hover: none` says there's no mouse.
|
||
|
||
It exists because these checks were being re-typed into a console every time
|
||
something changed, and each time they only covered whatever had just been touched.
|
||
That is how the standings table shipped with a player-name column zero pixels wide
|
||
and kept it for two rounds.
|
||
|
||
## Which rule wins
|
||
|
||
`feltShape.test.js` is here because of a change that measured as correct and was a
|
||
no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside
|
||
`@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set
|
||
the same property back to 1. Both queries match a phone, the declarations have the same
|
||
specificity, and the later one wins — so the property was 1 on every phone on the site
|
||
while the rule that was supposed to change it sat there reading perfectly.
|
||
|
||
Nothing in the suite could have caught it and nothing in review did. A stylesheet is not
|
||
a list of rules for viewports; it is a list of rules in an order, and the question "what
|
||
does a 390px phone get?" has an answer that no single rule knows. So the test walks the
|
||
media queries the way the cascade does — every declaration of a property on a selector,
|
||
in source order, keeping the last one whose query matches — and asks what each of five
|
||
real phone sizes actually ends up with. It carries just enough of a media-query engine
|
||
for the forms this stylesheet uses, and refuses loudly rather than guessing when it
|
||
meets one it doesn't know.
|
||
|
||
The same file records a second thing, which is a floor that was added and removed. The
|
||
`max-height` that keeps the felt above the action bar has no lower bound, so a tall
|
||
enough bar presses the table flat; flooring it at the desktop oval looked obviously
|
||
right. Measured, it was worse: twelve players all in with nine side pots takes the bar
|
||
to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its
|
||
shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all
|
||
twelve are visible. The slit the floor defended against turned out to be unreachable —
|
||
it came from forcing the custom property by hand. A test now asserts the floor is
|
||
*absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way.
|
||
|
||
## A property the wording depends on
|
||
|
||
`potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it
|
||
leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The
|
||
main pot is everyone still live, and each side pot above it drops whoever was all-in
|
||
below, so every side pot's players are a strict subset of the pot beneath it.
|
||
|
||
That makes "who is in" and "who is out" complements, which is what lets the line name
|
||
whichever of the two is shorter and never spell out more than half the table. Listing
|
||
every eligible player instead meant the main pot's line was every name at the table and
|
||
each side pot repeated it minus one — and the lines too long to fit were exactly the ones
|
||
nobody needed, while the short ones at the bottom were the side pots a dealer actually
|
||
announces by name.
|
||
|
||
The nesting check runs 20,000 random tables, and it asserts on its own generator as well
|
||
as on the code: if a change to the shapes stopped producing side pots, every assertion in
|
||
the loop would be vacuous and the test would still pass, so it also requires that more
|
||
than ten thousand of those tables had one.
|
||
|
||
Writing that check is where a comparison against `Set.length` went in — a `Set` has
|
||
`.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an
|
||
assertion it failed everything; as a filter in a throwaway script it had passed
|
||
everything, which is how it survived long enough to be written down. Both readings are
|
||
silent. Prefer asserting the count you expect over comparing two things whose shapes you
|
||
have not checked.
|
||
|
||
## Testing a fault that broke nothing
|
||
|
||
`sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because
|
||
the fault it covers was not a bug. Every control worked and every id resolved. What was
|
||
wrong was which panel a thing was in and what that panel was called.
|
||
|
||
The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and
|
||
Session held the standings, a waiting list, the hand log and the save/load/reset buttons
|
||
— a topic, a task, a log and three utilities under one word. It measured 1206px and 103
|
||
visible elements against 555 and 29 for the next biggest, and "The table itself" sat
|
||
1052px down inside it, so reaching Reset table meant opening a panel called Session and
|
||
scrolling past the money and the log to find it.
|
||
|
||
Three things are asserted, and each of them is a claim a future edit could quietly undo:
|
||
|
||
- **The names say what is inside them.** The list is checked literally, because the
|
||
failure mode is a word like "Session" that could hold anything.
|
||
- **Each name is a heading.** A `<summary>` is announced as a button, so the sidebar's
|
||
names — its whole navigation — were not reachable by heading. Splitting Session also
|
||
removed the four subheads that had been the only headings down there, so without a
|
||
heading in each summary the page would have come out of this with a worse outline than
|
||
it went in with.
|
||
- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is
|
||
legitimately long. It counts *subjects*: a panel that needs its own headings to list
|
||
its parts is holding more than one thing. Two subheads survive, and the test names
|
||
them, because Settle up is the standings at the end of the night and Stacks in chips
|
||
is the chip legend counted out — the same subject at a different moment, which is what
|
||
a subhead is for.
|
||
|
||
The panels are split on the opening tag rather than parsed, which is sound only because a
|
||
sidebar panel never contains another one; Table Setup's sub-panels carry a different
|
||
class. That assumption is worth knowing about if one ever does.
|
||
|
||
## A warning is only worth having if its number is right
|
||
|
||
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
|
||
24px button and was never a change to a legend. `computeContribution()` prices a seat by
|
||
running the legend over that seat's chip counts, so a colour that leaves takes every chip
|
||
counted in it off the table. Measured in a browser on a three-handed table: deleting the
|
||
$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said
|
||
afterwards, and no way back — a colour added again is a new colour with a new id, while
|
||
the counts stay keyed to the one that left.
|
||
|
||
Most of the file is about the number in the warning rather than the removal, because a
|
||
warning that overstates is worse than none. The obvious way to work it out — chips times
|
||
value — is wrong in **both** directions:
|
||
|
||
- a seat with a typed amount is priced from that and its counts are never read, so a
|
||
colour it holds is worth nothing to it;
|
||
- a seat whose chips all leave falls through to the table's standing ante rather than to
|
||
zero, so it loses less than its chips were worth.
|
||
|
||
So the figure is the difference between pricing every seat with the colour and without
|
||
it, and a randomised check across 2,000 tables asserts that what the warning claims is
|
||
exactly what the table then does. That check found a third case nobody would have written
|
||
by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table
|
||
**up** $186. The wording says so in that direction too.
|
||
|
||
The friction is conditional on purpose. A colour nobody has counted deletes on one click;
|
||
only one that is holding money asks. A confirmation that fires every time is one nobody
|
||
reads by the third night, and this is a tool where four other confirmations already
|
||
compete for that attention.
|
||
|
||
## Every control says what it is
|
||
|
||
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
|
||
had no accessible name, so it was announced as a button and nothing else — five of them
|
||
in a column, identical, each deleting a different colour.
|
||
|
||
It treats a symbol as no name even though `×` is technically text. The rule is that a
|
||
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
|
||
Names are resolved the way the accessibility tree does it — `aria-label`, then
|
||
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
|
||
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
|
||
skipped rather than judged, since it is not in the tree to begin with.
|
||
|
||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||
how it was checked.
|
||
|
||
## Where the keyboard lands
|
||
|
||
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
|
||
not be completely hidden by something the page has put on top of it. Tabbing the whole
|
||
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
|
||
under the action bar; on the desktop, four more under a toast.
|
||
|
||
They are checked as the fix rather than as the symptom, because the symptom needs a
|
||
keyboard and this file has no automation of its own:
|
||
|
||
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
|
||
least as much room as the fixed furniture standing at the bottom of the window. Only
|
||
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
|
||
down the right-hand side would demand scroll padding the height of the whole window;
|
||
and sticky elements inside a panel that scrolls on its own are excluded, because
|
||
scroll-padding on the document would do nothing about them.
|
||
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
|
||
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
|
||
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
|
||
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
|
||
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
|
||
|
||
### Half a fix looks exactly like a whole one
|
||
|
||
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
|
||
applies when the browser has to scroll to reach the control at all; a control the browser
|
||
already considers visible is one it will not scroll to, and a fixed overlay on top of it
|
||
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
|
||
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
|
||
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
|
||
outlast an eight-second toast.
|
||
|
||
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
|
||
and, when the page has no room left to scroll (26px available against 94px wanted, at
|
||
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
|
||
well would have fixed it by making the page grow and shrink by 64px every time anything
|
||
was announced, which is worse than the fault.
|
||
|
||
## A sweep only measures the states it reaches
|
||
|
||
The action bar is fixed over the felt, so its height is the felt's budget — and the bar
|
||
carries the side-pot breakdown, which drew one row per all-in with no ceiling. Measured
|
||
on a 360x640 phone: five players idle put the bar at **151px**, six all-in at **303px**,
|
||
and twelve all-in at **411px** — 64% of the screen, leaving **96px of felt** to draw
|
||
twelve seats in.
|
||
|
||
Nothing here could see it, and the reason is worth writing down. Every check in
|
||
`uiAudit.js` asks whether some element is covered, clipped or unreachable; not one asked
|
||
how much room the covering element had taken. But the deeper miss was the sweep itself:
|
||
all 294 audits drove their table with `Call` and `Check`, so **no table in the history of
|
||
this file ever had a player all-in**, and the one thing on the bar whose height depends
|
||
on the table was empty in every single measurement. `runUiAudit.js` now has a fourth
|
||
shape, twelve players shoved in for twelve different amounts — there is no one-tap shove,
|
||
so it takes the raise pad's All-in preset and confirms, or `Call all-in` for anyone who
|
||
cannot cover.
|
||
|
||
- **`furnitureBudget()`** — the fixed bottom band, measured against the window. Failing
|
||
above **50%**, which is the line that needs no taste to defend: furniture larger than
|
||
the content it serves is a shape fault whatever it is showing. Warning above **30%**,
|
||
the published guidance for persistent chrome, which the shortest phone does not meet at
|
||
rest — the bar is 151px of a 640px screen before anything happens — and a sweep that is
|
||
always red stops being read.
|
||
|
||
### Cutting the middle out of a list, and which end to keep
|
||
|
||
The cap is four layers, and past it the bar draws the main pot, one line for everything
|
||
between, and the newest side pot. The two ends are not an arbitrary choice: side pots are
|
||
paid **from the newest one back down to the main pot**, so the top layer is the next thing
|
||
that happens and the main pot is the last, and those are exactly the two a live dealer
|
||
says out loud while the chips are being stacked — *"main pot 7,200, side pot 4,500"*. The
|
||
layers between are the middle of that count-down, wanted at showdown, which is the pot
|
||
wizard — the same builder, passing no cap, in a dialog that scrolls.
|
||
|
||
`potBreakdownRoom.test.js` checks the parts that could go wrong quietly:
|
||
|
||
- the row count is a function of the cap, not of the table — eight, nine, ten, eleven and
|
||
twelve all-in all draw three lines
|
||
- the drawn amounts still add up to every pot, so a dealer counting the announcement
|
||
against the chips in the middle finds no shortfall the tool caused
|
||
- the elision sits **where the gap is**, not appended after the list, so the amounts still
|
||
read in the order the pots were made
|
||
- it never fires when it would not buy anything: at exactly the cap the middle is one row,
|
||
and swapping one row for one row loses an amount and gains a sentence
|
||
|
||
Three injected faults — the cap ignored, the summary carrying only the first elided pot's
|
||
money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests
|
||
respectively, and the audit check reports the original 64% when the cap is taken out.
|
||
|
||
## Two empty rows were setting how far apart twelve seats had to sit
|
||
|
||
Every seat puck carries four rows: the name, the hole cards, the amount that player has
|
||
put in, and their stack. Two of those are empty most of the time, and both held their
|
||
space anyway.
|
||
|
||
The amount pill was worse than empty space. It has a background, a pill radius and side
|
||
padding, so with no text it painted an **8x11px green lozenge** — measured on **seven of
|
||
nine seats** at an ordinary table. One of those is a component holding its place; nine is
|
||
a rash, and a dealer has to learn to ignore a colour that on the other two seats means
|
||
"this player has money out".
|
||
|
||
The space was not free either. `seatRingRadii` sizes the ring from the tallest puck on
|
||
it, so two rows nobody had filled were setting the spacing for the whole table:
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| iPhone 14, 9 seats | 78x88 puck | **78x63** |
|
||
| iPhone 14, 12 seats | 44x65 | **66x55** |
|
||
| Pro Max, 9 seats | 84x88 | **96x63** |
|
||
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
|
||
|
||
- **`emptyDecoration()`** — nothing inside `#pokerTableWrap` may paint a background or a
|
||
border with no content in it. The rule is about paint, not space: an element may hold
|
||
its place invisibly, and several do. With the old reservation restored it reports all
|
||
seven pills; live it reports none.
|
||
|
||
The one exemption, `.betChipDisc`, is on the list because the check reported it, not
|
||
because it was predicted — it is a 13x13px circle filled with the denomination colour of
|
||
the chip beside it, so the colour *is* the content. Three other exemptions were written
|
||
from memory first and matched no element on the page at all, which is the argument for
|
||
building that list from a run.
|
||
|
||
### The collapse is only safe because the cache key moved
|
||
|
||
`resolveSeatLayout` measures ten candidate widths, each one a forced layout, and caches
|
||
the result against `seatShapeSignature()`. That signature listed the names and the dealer
|
||
seat — everything that could wrap a name onto a second line. It did not mention the two
|
||
rows, because they always held their space and so could never change a puck's height.
|
||
|
||
Once they collapse, they can. `seatPuckShape.test.js` checks the key moves when the first
|
||
chips go in and when a hole card is entered, and — just as important — that it does *not*
|
||
move for the second player to bet, or when an amount merely gets wider. It is a boolean
|
||
over the whole table because that is what the measurement is: `measuredSeatBox` takes the
|
||
tallest puck on the ring, so what matters is whether anybody has one. A per-seat key would
|
||
be correct and would throw away the cache on every single tap.
|
||
|
||
Measured on the running page: the ring settles by 24-26px when the blinds are posted —
|
||
and it settled by **23-25px there before this round too**, because the action bar changes
|
||
height at that moment and the felt is measured against it. The one place the ring moves
|
||
is a place it already moved.
|
||
|
||
### A test that passed for the wrong reason
|
||
|
||
The signature joins names on a control character so that "ann" + "abel" and "anna" + "bel"
|
||
stay distinguishable. The first version of that test used `Ann`/`Abel` against
|
||
`Anna`/`Bel` — which can never collide whatever the separator, because the capitals land
|
||
in different places. It passed with the separator deleted and proved nothing. Lower case
|
||
is what makes it a test. Four faults were injected in total; the other three (dropping the
|
||
two flags, letting the count into the key instead of a boolean, and counting only hand
|
||
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
|
||
|
||
## Room the felt had and the type never got
|
||
|
||
The felt grows with the window and the pucks did not — 96px was the only ceiling there
|
||
was. Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop
|
||
and **8% at 1440px and beyond**, with the stack figure a dealer reads at **10px on a
|
||
2560px monitor**, the same 10px as on a 360px phone.
|
||
|
||
Two things stop the fix costing more than it buys, and `seatWidthSearch.test.js` checks
|
||
both:
|
||
|
||
- **Every candidate width is a forced layout.** Raising the ceiling for everybody took
|
||
the layout pass from 24ms to 73ms twelve-handed on a desktop. A screen that cannot use
|
||
the wider widths must not measure them, so a phone is checked to measure *exactly* the
|
||
ten widths it measured before — same list, same order — and the measured cost on a
|
||
phone is unchanged (10.7ms against 10.4ms, which is noise).
|
||
- **A wide puck on a narrow felt is worse than a small one.** The first version let
|
||
phones through, and a 144px puck on an iPhone's 278px ring is half the width of the
|
||
table: six-handed, all six seats sat over the rail by 15px against 5px before, and it
|
||
read as a stack of cards rather than a table with people around it.
|
||
|
||
So the ceiling is a fifth of the felt's **shorter** span, capped at 144. Five is measured,
|
||
not chosen: the shorter span is 233-303px on every phone in the sweep including sideways,
|
||
and 617-714px on tablets and desktops, so any divisor between 3.2 and 6.4 separates them.
|
||
The test pins both ends, so if the felt ever changes shape enough to bring those together
|
||
it says so rather than letting the phones quietly start paying.
|
||
|
||
Above the ordinary ceiling the puck also takes the larger type — `.seatsRoomy`, two steps
|
||
up the scale for the two numbers read across the table and one for the chair number.
|
||
Steps rather than a `clamp()`, for the same reason the tight set is: the width is a
|
||
continuum and the type is a set of deliberate sizes.
|
||
|
||
| nine-handed | before | after |
|
||
| --- | --- | --- |
|
||
| iPad puck / name | 96px / 12px | **134px / 15px** |
|
||
| desktop 1440 puck / name | 96px / 12px | **140px / 15px** |
|
||
| seats as a share of the felt, 1440 | 8% | **12%** |
|
||
| every phone, every seat count | unchanged | unchanged |
|
||
|
||
### What the rail check is actually measuring, which is not the rail
|
||
|
||
Found while checking the above and **not fixed** — written down here because the number is
|
||
worth having and the fix is not a small one.
|
||
|
||
`#pokerTable` is `border-radius: 50%`. Both the settle pass in `render.js` and
|
||
`seatsOnTheTable()` in the audit compare a puck against the four edges of the table's
|
||
**bounding box**, which is a different and much slacker question than the one the message
|
||
"hangs Npx past the rail" claims to answer: at the top and bottom of the ring the oval
|
||
curves away from its own box, so a puck can be comfortably inside the box and visibly
|
||
outside the felt.
|
||
|
||
Measured against the ellipse instead — a point is outside when `(dx/a)² + (dy/b)² > 1`,
|
||
and the ray from the centre crosses the boundary at `1/√t` of the way out — across 35
|
||
configurations the check is seeing **303px of overhang as zero**, including **46px on a
|
||
phone held sideways** that has been there for as long as the check has.
|
||
|
||
Settling against the ellipse was tried and reverted, because it is not the local fix it
|
||
looks like. Pulling the ring in far enough to clear the oval crowds the seats, and on a
|
||
twelve-handed phone with everybody all-in it put seat 3's centre inside seat 4 — the one
|
||
trade the width search explicitly refuses to make, since a dealer can see a puck over the
|
||
rail but cannot see a tap answering for the wrong player. Guarding against that instead
|
||
leaves the ring out and the overhang worse: the sweep went from 6 failing configurations
|
||
to 77, with 355 rail reports.
|
||
|
||
Which is the finding. On a crowded table **keeping the seats off the rail and keeping them
|
||
off each other are in genuine conflict**, and resolving it needs the two settled together
|
||
rather than one bolted onto the other.
|
||
|
||
### And then measured again, and dropped
|
||
|
||
Re-probed across 40 configurations — four table shapes at ten viewports — the gap is real
|
||
and systematic: **31 of 40 sit further past the oval than the box says**, worst 34px
|
||
against a box overhang of 0 everywhere. The 303px above was the all-in shape, which is the
|
||
extreme rather than the case.
|
||
|
||
Then it was looked at rather than measured, and 12–17px of a rounded puck's corner
|
||
crossing the rail reads as players *at* a rail — which is what the thing is a picture of.
|
||
The measurement gap is worth writing down and the appearance is not worth a round, so the
|
||
lead is closed rather than carried forward. Worth the re-measure to find that out: the
|
||
number that made it look urgent was the worst cell in the table, not a typical one.
|
||
|
||
## Eight rows that said only their own names
|
||
|
||
The sidebar is eight `<details>`, and every one measured exactly 60px shut with nothing on
|
||
it but its label — 480px of a phone's screen, and 480px of a 660px desktop column, spent
|
||
saying "Standings", "Hands", "Waiting list". Whether the box balances, whether anyone is
|
||
waiting for a seat, how many hands have been played: the page had **all three worked out
|
||
already**, and charged a tap for each of them, on rows two thirds of which were empty.
|
||
|
||
That is the disclosure pattern's named failure — a header you have to open to find out
|
||
whether it was worth opening — and the answer the design systems give is the same one:
|
||
put the status on the header. So each shut panel now carries the one fact you would have
|
||
opened it for, and the panel keeps the sentence. "Off by $300" is the alarm; *which seat
|
||
is holding chips nobody wrote down* is a paragraph, and that is the half that belongs
|
||
behind the tap.
|
||
|
||
Two rules hold it together, and `panelGlance.test.js` checks both.
|
||
|
||
**A glance appears only when it has something a dealer would act on.** Eight rows that
|
||
always say something are eight rows that say nothing again, and a row reading "Nobody
|
||
waiting" all night is the clutter this went in to remove. So no waiting list, no notes and
|
||
no hands yet are all silence, and the bank check stays quiet over a table nobody has
|
||
bought into — which is what the panel does with its own check, for the same reason.
|
||
|
||
**A glance never says something the panel under it would contradict.** It is read off the
|
||
same state, never off the panel's DOM — a shut `<details>` has no rendered body to read —
|
||
and one test walks three buy-in figures asserting the glance's colour agrees with
|
||
`bankReconciliation`'s verdict and its number carries the same difference.
|
||
|
||
The layout half is `glancesFitTheirRows` in the audit, and it took three goes.
|
||
|
||
The first version asked only whether a row had grown taller than a row with nothing on it,
|
||
which sounds like the whole question and is not: a glance floated back into flow and
|
||
allowed to wrap **sailed straight through it**, because a float does not expand the box it
|
||
sits in — two lines of note hung out of a row that still measured exactly 44px. Asking
|
||
where the glance actually ended up, in both axes, is the question that has an answer.
|
||
|
||
The second version was wrong in the other direction, and **the sweep caught it on its
|
||
first clean run**: 94 failing configurations, 91 of the offenders this check's own. It
|
||
takes the shortest row in the sidebar as its idea of a bare one, and the Keyboard panel is
|
||
`display: none` under `pointer: coarse` — a touch device has no keys to press — so on
|
||
every viewport visited with a thumb the bar was a row that wasn't drawn at all, 0px, and
|
||
all seven real rows read as grown. Seven rows times thirteen viewports is exactly the 91.
|
||
Rows that aren't rendered aren't rows.
|
||
|
||
Which is the point of running the sweep against a check as well as against the page. A
|
||
narrowing fix has to be shown not to have blinded the thing it quietened, so the four
|
||
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
|
||
breaking it — and each is still caught, while the unbroken page stays clean.
|
||
|
||
## Half a feature, and the half that was missing
|
||
|
||
The card feature has always had two halves. Turn on the setting and every street asks
|
||
which cards came off the deck, and puts them on the felt. That half worked, on the
|
||
phone and on the desk, in a normal hand and in an all-in run-out.
|
||
|
||
The other half never got asked for at all. A dealer who entered all five community
|
||
cards, pressed **Showdown — resolve pot**, and 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.
|
||
The one moment the tool is certain to know the hands is the moment it stopped asking.
|
||
|
||
So the showdown button now asks, exactly the way the street button does: the sheet opens
|
||
on the first player, takes two cards, and moves to the next. `showdownCards.test.js` is
|
||
the list it asks from — who is on it and in what order — and three rules hold it
|
||
together, one test each way:
|
||
|
||
**Only when the dealer asked.** The setting is off by default, and with it off the list
|
||
is empty. A table that never wanted this never sees a sheet.
|
||
|
||
**Only what isn't known.** A hand already entered is an answer already given. Asking
|
||
again offers to overwrite it, so an entered hand drops out of the list and the rest keep
|
||
their order.
|
||
|
||
**In the order the cards come over.** The bar already announces "Alexandra shows first —
|
||
last to bet or raise" a line above the button. Asking about somebody else first would be
|
||
the tool contradicting itself in the same breath, so the list starts where the bar says
|
||
and goes round the table from there.
|
||
|
||
### The sheet has to be leavable, and leaving it has to mean something
|
||
|
||
Escape, Done and a tap on the scrim all closed the picker already. What they didn't have
|
||
was a way to say *why*, and a chain of four sheets needs to know the difference: a sheet
|
||
that filled its last slot means "next player", and a dealer closing one means "stop".
|
||
|
||
So `close()` takes a reason. Two callers pass nothing and don't care. The showdown chain
|
||
does, and it stops rather than skipping — a dealer who closes the sheet is saying the
|
||
tool has the wrong end of the stick, and the answer to that is to get out of the way, not
|
||
to put the same sheet in front of them three more times.
|
||
|
||
Stopping early is not a dead end, which is the part worth checking by hand. The wizard
|
||
still names whoever didn't turn over; closing it leaves **Showdown — resolve pot** on the
|
||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||
is computed from the cards on the table rather than remembered from the last attempt.
|
||
|
||
## A scroll was an edit, and it reached the saved table
|
||
|
||
The worst bugs in this tool are the ones that move money without saying so, and this one
|
||
was triggered by reaching for another panel.
|
||
|
||
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:
|
||
|
||
| gesture | before | after | saved? |
|
||
|---|---|---|---|
|
||
| focus the small blind, scroll up | $5 | **$5.50** | yes |
|
||
| focus a seat's stack, scroll up | $500 | **$501** | yes |
|
||
|
||
Both go straight through to `localStorage`, because these fields save on `input` like
|
||
everything else here. Nothing is announced, nothing looks wrong, and the tool has
|
||
invented a chip — on a table where the whole point is that the chip count is right.
|
||
|
||
The rule that fixes it is narrow on purpose. Every *other* way of driving a spinbutton is
|
||
deliberate: the arrow keys, the stepper arrows, typing. Scrolling is the one actuation
|
||
nobody means, so it is the only one taken away. Verified after the change: the value
|
||
holds, one ArrowUp still moves it, typing still works, **and the page still scrolls** —
|
||
0 to 300px, which matters, because the obvious fix does not.
|
||
|
||
### Why it blurs instead of cancelling
|
||
|
||
`preventDefault()` on the wheel stops the value moving and stops the page moving with it.
|
||
That turns a silent bug into a sidebar that mysteriously refuses to scroll, which is a
|
||
worse trade: the dealer now has a problem they can see and can't explain, instead of one
|
||
they can't see. Dropping focus lets the scroll do exactly what was asked and leaves the
|
||
number alone.
|
||
|
||
### One guard that was checked and found to be decoration
|
||
|
||
The predicate starts with `field.tagName !== "INPUT"`. Injecting a fault into it — taking
|
||
it out entirely — changed nothing: every test still passed, because nothing but an
|
||
`<input>` carries `type="number"`, so the type check below it already excludes every
|
||
textarea, select and button on the page.
|
||
|
||
That is written into the comment rather than quietly kept. A guard that cannot fail is
|
||
fine to leave in for symmetry with `selectsOnFocus` beside it; a guard that cannot fail
|
||
and is *presented* as load-bearing is how the next person wastes an afternoon.
|
||
|
||
The page's one `<select>` — which way the blinds move — was the obvious next suspect and
|
||
is not one: focused, hovered and scrolled 300px in Chromium it stays on "clockwise". So
|
||
it is left out of the rule rather than defended against, and the check keeps meaning
|
||
exactly one thing.
|
||
|
||
## The motion setting was honoured in the three places it mattered least
|
||
|
||
The stylesheet had answered `prefers-reduced-motion` for a while. All three places it did
|
||
were the wrong size of thing: a 2px press-down on a button, a checkbox's tick, and the
|
||
acting seat's pulse. What none of them covered was motion that moves something *across
|
||
the screen* — and for anyone with a vestibular disorder that 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, a bottom sheet that slides the full height of itself up from
|
||
off-screen every time a dealer taps a chair, which on a busy table is most of the taps
|
||
they make all night;
|
||
- the toast, sliding in from below;
|
||
- and three `scrollIntoView({ behavior: "smooth" })` calls — the most provocative of the
|
||
lot, because what moves is the whole viewport rather than one control.
|
||
|
||
The scroll calls now go through `Render.bringIntoView`, 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, and the table is a thing they cannot afford to reload lightly.
|
||
|
||
The toast keeps its fade and loses its travel. Deleting the animation outright is the
|
||
wrong answer — something that appears already in place reads as a rendering glitch rather
|
||
than as news — and the guidance that says to cut movement says to keep *something*.
|
||
|
||
The sheet needed one more thing than the obvious. Its `visibility` transition carries a
|
||
0.25s delay so the slide-out stays on screen for its own animation; with the slide gone,
|
||
that same delay leaves a sheet that has already jumped away still painted for a quarter
|
||
of a second. It flips at once under reduced motion, in both directions.
|
||
|
||
### A list whose membership was arbitrary, because it was solving another problem
|
||
|
||
The press-down block held nine `:active` selectors and looked deliberate. It wasn't. It
|
||
had been written to stop a *stuck press state* when a tap turns into a scroll, and it
|
||
reached for the reduced-motion query as the nearest available proxy — so it covered
|
||
`.dealerChip` and `.raiseQuickBtn` while missing `.actionBtn`, the class on Fold, Call,
|
||
Raise and Post blinds & deal, which is to say the most-tapped control in the tool.
|
||
|
||
Read as its own rule, membership is not a judgement call: **every `:hover` and `:active`
|
||
transform in the file belongs there.** There are sixteen. Nine were listed. The other
|
||
seven are now.
|
||
|
||
### The scan that found the last three, and the injection that found the scan
|
||
|
||
The general test scans the stylesheet for pointer transforms and fails on any without an
|
||
escape, so a new one cannot be added without the block being updated. It took two goes,
|
||
and both faults are worth keeping written down.
|
||
|
||
The first version scanned line by line: remember the last line ending in `{`, then look
|
||
for lines starting with `transform:`. A deliberately broken version was run at it — a new
|
||
hover lift, uncovered — and the scan **passed**. The rule had been written on one line,
|
||
so the selector and the declaration were in the same string and neither pattern matched.
|
||
|
||
Rewritten to parse blocks, it caught that. It also immediately failed on the *clean*
|
||
file, which is how the last three came to light: a multi-line comma-separated group had
|
||
been truncated to its final line by the old scan, so `.toastAction`, `.toastClose` and
|
||
`.potWizardChoice` had never been looked at by anything. A grouped rule is nine separate
|
||
promises, and it is now split on commas and checked one at a time — comparing the joined
|
||
string against the block would only have passed if both lists happened to be written in
|
||
the same order.
|
||
|
||
## Two pixels, and every seat on the felt paid for them
|
||
|
||
The sweep had carried the same residual for several rounds: on the two smallest phones
|
||
with twelve players, seats painted over each other's names and stacks. It read like a
|
||
geometry problem — twelve pucks, not enough rim — and the layout code had already been
|
||
round that loop twice, deriving the ring from the felt and searching for a puck width
|
||
that clears.
|
||
|
||
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` and
|
||
`puckAmountHuge` sizes exist to stop it doing that. Nothing had ever asked what the line
|
||
does when it *can* break. Measured at 360x640 with twelve players all-in: it wanted 36px
|
||
and had 34, so it broke onto a second line, and an 11px line became 22.
|
||
|
||
That is not a typography complaint, which is why it survived so long — 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
|
||
|
||
A line that cannot wrap and still does not fit paints over the seat next door instead —
|
||
the same failure, arriving sideways. So the mark has to actually *fit* 34px, and it
|
||
wanted 36.
|
||
|
||
Of the three things that separate "ALL IN" from a grey money figure at a glance — a warm
|
||
colour, a bold weight, and wider letters — the letter-spacing is the one that costs
|
||
pixels: 3.6px of the 36. On the tight tier it goes, and the other two stay. Measured
|
||
after: 33px of ink in a 34px box on a Galaxy S8, and the full spacing everywhere else,
|
||
because no other puck is short of the room.
|
||
|
||
The three of them are pinned in `allInMarker.test.js` against the stylesheet itself —
|
||
including the assertion that the *ordinary* spacing still doesn't fit, so that if someone
|
||
later widens the puck and makes the whole problem moot, the test says it is measuring the
|
||
wrong thing rather than passing quietly.
|
||
|
||
### And the bar was standing on the felt while nobody could act
|
||
|
||
Halving the overlap was not clearing 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 360x640 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 — and the list went on
|
||
standing there. During a run-out it now collapses to the main pot, which is the number a
|
||
dealer says out loud, and one line for everything else, which is what the pot wizard is
|
||
for and what that row has always said in as many words.
|
||
|
||
The **status row** said the player count twice in the same breath: a `12 players` chip,
|
||
and 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.
|
||
|
||
Measured across the three, on the table shape with the most seats to fit:
|
||
|
||
| | 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, Galaxy S8 | 192px | 210px |
|
||
| Felt height, iPhone SE | 243px | 283px |
|
||
| Worst seat overlap, iPhone SE | 34px | 4px |
|
||
| Worst seat overlap, Galaxy S8 | 34px | 17px |
|
||
|
||
The summary row is held to the same rule as the elision above it, and it is the rule that
|
||
matters most for anything that hides a number: **it must not lose a chip.** A dealer
|
||
counting the announcement against the chips in the middle of the table must not find a
|
||
shortfall the tool invented, so every row count is checked to add up to the pots it
|
||
stands for, at every table size from three players to twelve.
|
||
|
||
### What is left, and why it is a decision rather than a fix
|
||
|
||
**315 of 320 audits pass.** The five that don't are all one root cause and all on the two
|
||
smallest screens in the sweep:
|
||
|
||
- `Galaxy S8 · all in` (360x640), twelve players, every one of them all-in at once
|
||
- `Galaxy S8 · packed · raise pad` (360x640), twelve players with the pad open
|
||
- `iPhone SE · all in` (375x667), twelve all-in, on the mouse pass only
|
||
|
||
The twelve-handed table at 360x640 now passes with the betting live. It fails in the two
|
||
states that make the bar tallest, and 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 instead of pretending —
|
||
it keeps the narrowest puck on the widest ring and lets this sweep say so out loud.
|
||
|
||
The remaining chip offenders are the same shortage seen from the other layer.
|
||
`anchoredChipTravel` walks a chip back off a stranger's puck a pixel at a time, and stops
|
||
at its own puck's edge because there is nowhere further to go. When the pucks themselves
|
||
overlap, travel reaches zero and the chip is still under a neighbour.
|
||
|
||
Getting the last five to pass is a design decision and not another measurement: dropping
|
||
the bet chips at extreme crowding the way the tight tier already drops the hole cards,
|
||
capping the seat count by viewport, or letting the felt scroll. Each trades something a
|
||
dealer can see, so none of them should be picked by whoever is next holding a ruler.
|
||
|
||
## The contrast check was passing text nobody could read
|
||
|
||
The layout sweep has checked WCAG AA contrast for a long time and the page passed it.
|
||
That result was worth less than it looked, for two separate reasons.
|
||
|
||
The first is the instrument. WCAG 2's contrast ratio is a luminance gap, and a luminance
|
||
gap overstates readability once one of the two colours is near black — which is every
|
||
reading taken on this page, whose ground is `#141414` and whose panels are darker than
|
||
its rows. This is not a quibble about the spec; it is the spec's own working group's
|
||
position, and it is why APCA exists. Tuning a colour until it clears 4.5:1 on a
|
||
near-black ground lands on text that passes the audit and still reads as a smudge across
|
||
a table in a dim room. Several rules in the stylesheet said so in their own comments —
|
||
one of them "measured 4.5:1 at `#a5a5a5` exactly, so it sits above that" — which is the
|
||
right instinct aimed through the wrong lens.
|
||
|
||
The second is that the check could not see half the backgrounds it was scoring against.
|
||
It reads `background-color` and walks up the ancestors, so a background painted by a
|
||
gradient is invisible to it; and where several ancestors are candidates it takes *the
|
||
best* contrast of any of them. Put together, text on the felt was being scored against
|
||
the dark page behind the felt rather than the felt. Two plain AA failures had been
|
||
sitting in a clean report for as long as the check has existed: the chair number on an
|
||
empty seat, on the table's brown rim, at 2.65:1, and the pot's own label, gold on green,
|
||
at 2.66:1.
|
||
|
||
`runContrastAudit.js` is the answer to both. It scores with APCA, and it gets the
|
||
background by screenshotting the page with every piece of text made transparent and
|
||
reading the pixels underneath — which cannot be fooled by a gradient, a shadow or a
|
||
border, because it is looking at what was painted. The first version of it *did* walk the
|
||
DOM, and reported the gold dealer button as black-on-black at Lc 0.4; an element anyone
|
||
can see is legible. That false positive is why the tool works the way it does.
|
||
|
||
What it found, on 92 distinct pieces of text: 65 below the level APCA sets for the size
|
||
and weight they are drawn at, **54 of them passing WCAG AA at the same moment**. The
|
||
worst was Lc 28. After this round nothing on the page is below Lc 60, nothing fails AA,
|
||
and the worst reading is Lc 60.4.
|
||
|
||
Three of the fixes are worth naming, because none of them is "make the grey lighter":
|
||
|
||
- **A player's stack was styled as secondary text.** Muted grey at Lc 44, under a name
|
||
set in `--text` at a heavier weight. Read back the other way that says a dealer needs
|
||
the name more than the number, and during a hand the opposite is true — the name is
|
||
looked up once to find the chair, the stack is read on every action because it decides
|
||
whether a player can cover the bet. Hierarchy now comes from size and weight, which is
|
||
free, instead of from fading, which on a dark ground is the most expensive way to say
|
||
"secondary".
|
||
- **An empty seat had no background of its own**, just a 2%-white wash at 0.7 opacity, so
|
||
whatever the felt was doing behind it decided how readable its number was: Lc 28 on the
|
||
rim, 30 on the dark green, 33 further in. Contrast that depends on where a seat happens
|
||
to sit cannot be reasoned about. It has a ground now. The wash is 0.62 and not the 0.82
|
||
it was first written at, because contrast turns out to be nearly flat across that range
|
||
— two points of Lc — and the heavier one put four dark rectangles on the felt like
|
||
holes.
|
||
- **The pot's label sat on the one green in the design.** `--gold` is tuned for near-black
|
||
and measures Lc 70-odd everywhere else it is used; on the pot disc it was Lc 38. The
|
||
disc is deeper now rather than the label bleached, because gold on dark green is the
|
||
pairing the whole page is built on. Deepening only the disc's outer stop moved it two
|
||
points and no further — the highlight is at 32%/28% and that is exactly where the label
|
||
sits, so the reading was being taken on the brightest part of the disc.
|
||
|
||
### What is deliberately not fixed
|
||
|
||
APCA asks Lc 90 of anything at 12px/400 or below, and this page is full of 10px and 11px
|
||
labels that live inside a 58px seat puck. Fifty-five pieces of text are still below that
|
||
bar. Reaching it means white micro-labels and a flatter, shoutier page; the honest way to
|
||
clear it is bigger type, and bigger type is a layout change that would relitigate the
|
||
seat-crowding work above. So the sweep reports two numbers — the strict per-size level,
|
||
and the Lc 60 / Lc 75 floors this page actually holds itself to — and only the floors are
|
||
treated as pass/fail.
|
||
|
||
## The failures that only happen at the table
|
||
|
||
Everything above this line is about a tool being easy to read and quick to press. This
|
||
section is about it being *there* — the three ways it could let a dealer down on the
|
||
night that no amount of layout work would have caught, because none of them show up on a
|
||
developer's desk with a good connection and a plugged-in laptop.
|
||
|
||
### One spelling of the URL, because the offline promise depends on it
|
||
|
||
The manifest says "Works with no signal", and it did — at `/casino/texasholdem`.
|
||
|
||
Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical
|
||
page. A service worker's scope is not: the spec matches it as a plain string prefix, so a
|
||
worker scoped to `/casino/` does not control a page at `/Casino/TexasHoldem`. Measured on
|
||
the capital-C spelling, 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, and it took a second look to understand. The
|
||
registration *succeeded*. The worker *activated*. Nothing was logged. But `registerWorker`
|
||
handed its precache list to `navigator.serviceWorker.ready`, and `ready` resolves with the
|
||
registration **controlling this page** — so on a page outside the scope it never resolves,
|
||
the message is never sent, and not one file is stored. A dealer who had opened that URL to
|
||
"load the tool for tonight" had loaded nothing, and had no way to find out until the
|
||
signal went.
|
||
|
||
So one spelling wins: a permanent redirect in `Program.cs`, scoped to `/casino` rather
|
||
than applied site-wide, because every file under `wwwroot/casino` and
|
||
`wwwroot/images/casino` is already lowercase and 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` in particular would break.
|
||
|
||
The precache handshake was fixed too, rather than left to be saved by the redirect. It
|
||
now takes the worker off the registration itself — `installing || waiting || active` —
|
||
and waits for `activated` on that. A tool whose offline support silently depends on being
|
||
in scope is one URL away from having none.
|
||
|
||
### And what the document never mentions
|
||
|
||
The shell list is read off the DOM on purpose, 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 no tag in the document points at them and the sweep missed all four. 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, for exactly the same
|
||
reason. 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 sweep picks up the
|
||
stylesheet, icon and apple-touch links together. Twenty-five entries, all four icons.
|
||
|
||
Reading the manifest offline is deliberately cache-first, which is backwards from how the
|
||
rest of the 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, in exchange for re-reading four paths that were already cached.
|
||
|
||
### A lock is easy to take and easy to leave holding
|
||
|
||
A dealer's phone lies face-up on the felt all night, and dims and locks between the flop
|
||
and the turn every hand. The Screen Wake Lock API fixes that in about four lines, and the
|
||
risk runs entirely the other way: a lock that is taken and *not* dropped burns the battery
|
||
through a break, a re-buy, and twenty minutes of arguing about pizza, with nothing on
|
||
screen to say why.
|
||
|
||
So the rule is narrow — held only while betting is actually open — and the tests are
|
||
mostly about letting go:
|
||
|
||
- **A whole street of renders takes exactly one.** `sync()` runs off `renderAll`, which
|
||
fires many times a second while a street is being bet.
|
||
- **Two syncs in the same tick take exactly one.** Without the `pending` guard both get
|
||
past the "already holding one?" check and the second sentinel is never stored, so
|
||
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, and the sentinel that arrives afterwards
|
||
belongs to a hand that no longer exists.
|
||
- **The browser taking it back leaves us able to take it again.** This is not an edge
|
||
case: the browser releases the lock itself every time the document stops being visible,
|
||
and never gives it back. A tool that takes one at the start of the hand and assumes it
|
||
still has it works until the dealer checks a rule on their phone.
|
||
|
||
#### A fake that wasn't installed, and four tests that agreed
|
||
|
||
The first run of these had eight failures and, worse, one pass: "a browser without the API
|
||
is left alone entirely" was green from the start.
|
||
|
||
`globalThis.navigator = fake` does nothing. Node has had its own `navigator` since v21,
|
||
defined as a getter, so the assignment silently fails — no error, no effect. Every test in
|
||
the file was measuring Node's real navigator, which has no `wakeLock`, so the acquire
|
||
tests failed honestly and the unsupported-browser test passed for precisely the wrong
|
||
reason. `Object.defineProperty` installs it properly.
|
||
|
||
Then five faults were injected and all five were caught: dropping the `pending` guard,
|
||
keeping a lock the hand no longer wants, ignoring the browser's own release, holding it
|
||
between hands, and reading a missing `keepScreenAwake` as off instead of on.
|
||
|
||
### 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 is free to 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, with
|
||
nothing having gone wrong and nobody having deleted anything.
|
||
|
||
`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**, the moment this stops being a setup screen and starts being a night somebody could
|
||
lose. Chromium and Safari answer silently on their own engagement heuristics; Firefox
|
||
asks. A no is not an error worth reporting at a table, so the only thing that changes is
|
||
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
|
||
|
||
## Why the hand evaluator is tested the way it is
|
||
|
||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||
noticing. A pot wizard that asks "who won?" is never more wrong than the dealer ticking
|
||
the box; one that answers the question itself is wrong on its own account, silently,
|
||
and a table that has come to trust it will not check.
|
||
|
||
So besides the hand-written cases — every category against every other, the wheel, three
|
||
kickers deep, the counterfeited two pair, the board that plays — it is checked against a
|
||
second evaluator written a different way, on twenty thousand random seven-card hands and
|
||
four thousand random pairs of them. The second one is in the test file and shares no
|
||
helper with the module: it scores a hand by counting ranks into a sortable list rather
|
||
than by asking about shapes. That pass is most of the suite's running time and it is
|
||
worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel,
|
||
a dropped two-pair kicker, a flush read only by its top card — are each caught by it as
|
||
well as by the cases written for them.
|
||
|
||
## Why the randomised suite exists
|
||
|
||
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||
to empty chairs, the button skipping past players, heads-up acting out of order, an
|
||
overbet announced as a side pot. None of them show up on a full six-handed table with
|
||
equal stacks, which is the table anyone writes a fixed example for.
|
||
|
||
`invariants.test.js` deals a thousand hands across random seat counts, random empty
|
||
chairs, random stack depths and random dealer input, and checks the things a dealer
|
||
would notice being wrong:
|
||
|
||
- chips are never created or destroyed during play
|
||
- the pot breakdown plus the uncalled returns always equal the money that went in
|
||
- paying the pot out settles it to the cent
|
||
- once uncalled chips are handed back, nobody is still betting above the whole table
|
||
- the button and both blinds always land on players who are actually in the hand
|
||
|
||
The generator is seeded, so a failure reproduces from the seed printed in the message
|
||
rather than being a flake to re-run.
|