Seven rounds on this branch. Each starts from the same kind of finding: a number that was right about one thing being used for another.
Round one — the phone on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which they cannot, so seven of nine finished past the rail with up to 17px of puck floating on the page. That single fault was 96 of the 98 offenders the layout audit could find anywhere.
The shortage was never in the table. The bar was 205px tall and 529 wide to hold three buttons and a status line, taking that height from the one element that needed height. In landscape it becomes a rail down the right-hand side, and the felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and a gap between them. It also puts Fold, Call and Raise under the right thumb rather than along the bottom edge, where a landscape grip has the palm.
Which layout the bar is in is declared by the stylesheet in a custom property and read back by actionBar.js, because the one thing that must not exist is a second copy of that media query written in JavaScript.
The ring stopped predicting itself
seatRingRadii works out the widest ring whose outermost puck still finishes inside the rail, and it is right about a puck it measured before the width search settled and before a name wrapped or was shortened — each worth up to 15px of height arriving after the answer. Four viewports finished with the top seat 5-6px over.
render.js now checks the drawn result the way the audit judges it: if the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed again.
A layout that predicts itself has to be right about the future. One that measures itself only has to be right about the present.
Also
Names shorten at a word instead of being chopped mid-one. "Mike from work" on a nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word, because "Mike from" looks cut off rather than shortened.
The blind badge is counted in what a seat needs — it points away from the table, which was taken to mean it costs nothing between two seats: true of a straight rail, false of a ring. It also got shorter, 15px to 12, so clearing its own name costs every chair four pixels instead of nine.
Border-box, inherited, for the whole app. Both elements that hadn't opted in went wrong the same afternoon: the rail overran the page's padding by 25px, and the raise pad's amount field pushed a horizontal scrollbar across a 640px phone.
The raise amount field is thumb-sized. The rule that sizes fields for a finger names the settings panels and the seat form — the one field on the one surface built for thumbs was 36px on every phone in the list.
mobile-web-app-capable alongside Apple's older name, which Chrome had been calling deprecated in this page's console on every load.
Round two — the desktop
The action bar was centred on the window. The table is centred on its own column, and the sidebar takes 320px out of the right of that — so the two things a dealer is looking at sat 172px apart, at 960px wide and at 1600px and everywhere in between, because the offset is half the sidebar and the sidebar is a constant. The bar also finished 80px inside the sidebar's column at 1280, painting over whichever panel happened to be at that height.
It leans away from the sidebar now, by the sidebar's own width, which comes out exact above and below the 1400px cap — measured at four widths, 0px off at all of them. No 100vw in it, which is the point: the bar's own centring comment warns that on a page with a scrollbar 100vw is wider than the viewport.
The dealer button was squeezing a name. Its chip sits in the puck's top-right corner and the name kept clear with padding-right: 22px — which takes the chip's width off every line, and the chip is on one of them. At 1280x900 the name box is 78px, "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and broke as "Christop / her". Mid-word, no hyphen. The button moves every hand, so that is a player whose name reads properly for four hands and comes apart on the fifth.
A float indents only the lines it sits beside, so a name too long to fit next to the chip drops below it whole. Not on a crowded ring, though, and finding that out cost a sweep: a tight puck gives the name one line on purpose, and one line has nowhere to move to — the name landed on a second line the chip's lower half was still sitting on. So the float is for the ring with room for two lines and the padding for the one without.
What the sweep learned
Text is measured where it was painted, not where its box is. The seat-label check modelled "text kept out of somewhere" as padding, which is one way of doing it and not the only one. 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. "Is under a badge" reads the same for a stack a dealer cannot read and for a rounded corner grazing the last digit. Probing the whole line turns the residual this sweep has carried for three rounds into 1/7 under SB.
The sweep opens the raise pad, which nothing ever had — it is part of the bar rather than an overlay, and it is both the tallest thing the bar holds and the only place a dealer types an amount.
Round three — the felt was the wrong shape for a phone
A portrait phone got a square felt. A square box that is width: 100% of its column can never be taller than it is wide, so the vertical room the layout had already gone to some trouble to measure was published and then thrown away: 522px of room under a 366px table on an iPhone 14, 638 under 406 on a Pro Max. Nothing was wrong with the arithmetic. The shape refused the answer.
The cost was seat names — 44px pucks with five of twelve clipped, "Christopher" and "Alexandra" both cut. The wide layout has been an oval lying down (0.72) all along; a phone is a portrait screen and this is a table seen from above, so --feltAspect: 1.5 is the same decision made the other way up.
before
after
iPhone 14, 12 seats
44px puck, 5/12 clipped
66px puck, 1/12
iPhone 14, 5 seats
54px puck, 3/5 clipped
78px puck, 0/5
Pixel 7, 12 seats
44px, 5/12
72px, 0/12
Pro Max, 12 seats
44px, 5/12
72px, 0/12
1.6 and 1.7 measure byte-identical to each other on every phone, because by then the room is binding again — so 1.5 is where the sweep stops paying. The three smaller phones are room-bound at any cap and measured identical from 1.15 through 1.7, so this cannot regress them.
The first version of this was a no-op
The rule went into @media (max-width: 700px). Forty lines below, @media (max-width: 900px) set the same custom property back to 1. Both queries match a phone, the declarations have identical specificity, and the later one wins — so --feltAspect was 1 on every phone on the site while the rule that was supposed to change it sat there reading perfectly. It was only caught by measuring the drawn page and finding the number hadn't moved.
Nothing in the suite could have caught that 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 "what does a 390px phone get?" is a question no single rule knows the answer to. The 900px rule is now a 701–900px range, and feltShape.test.js resolves the property the way the cascade does — every declaration on a selector, in source order, keeping the last one whose query matches — against five real phone sizes. 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.
A floor that was added and then measured out again
max-height: var(--feltRoomPx) has no lower bound, so a tall enough bar presses the table flat. Flooring it at the desktop oval — max(var(--feltRoomPx), 72cqw) — looked obviously right, and went in.
It made things worse. Twelve players all in with nine side pots is the tallest the bar gets, 341px, which at 360×640 leaves 142px of room. Floored to 242 the felt keeps its shape by putting five of the twelve seats behind the action bar; unfloored at 142 it is a flat table with all twelve visible. Same on an iPhone SE: three seats against none. A covered seat is the exact fault this whole measurement exists to prevent, and a flat table is only ugly.
The slit the floor defended against was never reachable either — it came from forcing --feltRoomPx to 10px by hand. The real floor is FELT_FLOOR_PX at 96, and the worst any of these phones actually publishes is 142, which lays out fine. A test now asserts the floor's absence, with the numbers, so it doesn't get re-added by someone reasoning the same way I did.
Round four — who can win this pot
The pot breakdown listed every eligible player for every pot. Pot eligibility is nested — the main pot is everyone still live, and each side pot above it drops whoever was all-in below — so the main pot's line was every name at the table, and each side pot repeated that line minus one.
The lines too long to fit were exactly the ones nobody needed spelled out, and the short ones at the bottom were the side pots a dealer actually announces by name ("side pot is 80 between Ben and Carlos"). There was no count and no title, so a dealer reading Alexandra… could not tell whether three people or eleven were eligible — which is the number the showdown turns on, since side-pot hands are shown first.
Measured on an iPhone 14 before: a single all-in five-handed already clipped the main pot, and twelve-handed the first row asked for 521px of a 254px slot.
Each line now names whichever of "who is in" and "who is out" is shorter — nesting makes them complements, so that is never more than half the table — and leads with the count, because the line is right-aligned with an ellipsis and it is the tail that gets eaten.
all-ins
before
after
one, 5 handed
main pot clipped
nothing clipped
two, 6 handed
2 rows clipped
nothing clipped
three, 9 handed
all 4 rows clipped
nothing clipped
twelve handed
6 rows clipped
5, and a cut row still opens 9 in
Same on a Galaxy S8. The result reads as Main pot $360 · all 9, Side pot 1 $400 · 8 in, not Mike from work — and it is self-checking, because the excluded names are exactly the players showing ALL IN on the felt, in stack order.
The wizard's copy of the same announcement wraps rather than truncating: it is a dialog that scrolls, and it is what the dealer reads out before anybody shows a hand, so nothing there is ever cut off. The full list is also on the title for anyone with a pointer — the extra, not the answer, since a tooltip is not a mechanism a keyboard reaches.
The property underneath
potCalculator.test.js now proves the nesting across 20,000 random tables, and asserts on its own generator too: 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 believed. Both readings are silent, and the first version of this PR description would have claimed a property only half of which had been checked.
Round five — the sidebar was five names and a cupboard
Session was a container word rather than a name. It 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 label, which is the "don't mix apples and oranges" failure: one navigation layer carrying more than one logic.
It measured 1206px and 103 visible elements, against 555 and 29 for the next biggest panel. "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 — and both destructive buttons were at the bottom of that scroll.
before
after
biggest panel
Session, 1206px / 103 els
Chip Legend, 607px / 37 els
depth to Reset table
1052px inside "Session"
62px inside "Save & reset"
panels
5
8
collapsed height at rest
588px desktop / 420 phone
804 / 636
It is now Standings, Waiting list, Hands and Save & reset, each named for the thing a dealer came looking for, in the order of a night: set the table up, watch the money, fill the seats, look back at a hand — and last and apart, the two buttons that end it. Naming the panel for both jobs puts the danger on the label, read before the panel is opened rather than met at the end of a scroll. The four subheads that used to carry those names are gone, because the summary says it now.
The cost is honest and stated: 216px more collapsed summaries at rest, three extra one-line labels. The working surface — felt and action bar — is untouched.
The outline came out better than it went in
A <summary> is announced as a button, not a heading, so the sidebar's names — its whole navigation — were never reachable by heading. The only headings down there were the four subheads buried inside Session, and splitting it would have taken those away.
Every panel name is now an <h2> inside its summary, which the spec allows and both VoiceOver and NVDA expose. The outline is h1 page → h2 per panel → h3 for the two subheads that survive. Visually nothing moved: the heading gives up its margin, size, colour and spacing, and is display: inline so it doesn't push the disclosure triangle onto its own line.
A test for a fault that broke nothing
sidebarPanels.test.js reads the .cshtml, because nothing here was broken — every control worked, every id resolved, no test could have failed. What was wrong was which panel a thing was in and what that panel was called. It asserts the names literally, that each is a heading, and that no panel goes back to being a grab-bag — counting subjects rather than size, since a panel needing its own headings to list its parts is holding more than one thing. Two subheads survive and the test names them: Settle up is the standings at the end of the night, Stacks in chips is the chip legend counted out.
Round six — a chip colour was carrying the table's money
Deleting a chip colour was one click of an unlabelled 24px ×, five of them in a column in a reference panel. It 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 counting real chips:
on the table before
$26,874
after one click on the ×
$4,374
gone
$22,500 — 84%
confirmations shown
0
recoverable
no
Not recoverable because a colour added back is a new colour with a new id, while the counts stay keyed to the one that left — so "put it back" restores the row and none of the money. The orphaned counts stayed in the saved file too, money that nothing could price and nothing could show.
Friction only where it is earned
A colour nobody has counted still deletes on one click. One that is holding money asks first, naming the amount, the chips and the seats, and saying plainly that Undo will put it back while adding the colour again will not. Removal now goes through runUndoable, and the counts leave with the colour so the file matches the screen.
That split is the point rather than an optimisation. A confirmation that fires every time is one nobody reads by the third night, and this tool already has four others competing for that attention — the effectiveness of a confirmation is a function of its rarity.
The number in the warning is the number the table loses
A warning that overstates is worse than none, and the obvious arithmetic — 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. A randomised check over 2,000 tables asserts that what the warning claims is exactly what the table then does — and it 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.
Every control says what it is
The × had no accessible name, so it was announced as a button and nothing else — five identical ones, each deleting a different colour. That is a WCAG 4.1.2 failure and, per WebAIM's 2025 sweep, among the most common on the web.
uiAudit.js gained controlNames(), which is the general form of it rather than a fix for the one button: every control needs a name containing a letter or a digit, resolved the way the accessibility tree resolves it — aria-label, aria-labelledby, a field's wrapping or pointing <label>, placeholder, text content, an image's alt, title — with anything under aria-hidden skipped rather than judged. A symbol is not a name: × is announced as "times". Taking the new label off again makes it report all five buttons, which is how it was checked.
Round seven — the bar was sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden by something the page has put on top of it. The action bar is fixed over the bottom and is therefore not in the flow, so the browser does not know it is there: Tab to a control near the bottom and the browser scrolls it flush against the edge of the window — under the bar — and considers the job done.
Measured by tabbing the whole page:
before
after
completely obscured, 390x844 phone
16 stops
0
partly obscured, same phone
27
0
completely obscured, 1280x900 desktop
4 (under a toast)
0
phone on its side
0
0
Clean against 2.4.12 (AAA) as well, which allows no overlap at all.
Three faults, three different answers
scroll-padding-bottom on the scroll container, sized from the bar's own published height plus the toast's, handles the case where the browser has to scroll to reach the control at all.
That is half of it, and it is the half that looks like the whole one. scroll-padding only applies when the browser scrolls; a control it already considers visible is one it will not scroll to, and a fixed overlay on top does not enter into that decision. Measured on the desktop: tabbing to Common rulings with a toast up left it 100% covered, and scrollIntoView({block: "nearest"}) moved nothing, because "nearest" was already satisfied. The first sweep had come back 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 exactly it — and when the page has no room left to scroll (26px available against the 94px wanted), the toast gives way instead. Sizing the page's bottom padding for the toast as well would have fixed that by making the page grow and shrink by 64px every time anything was announced, which is a worse thing to do to a dealer than the fault. On a phone, where there is room, the scroll happens and the toast stays up.
The page's room to scroll past the bar was 9rem on the sidebar alone, which gave the column of panels somewhere to go and left the table's column — most of a desktop — with nothing. It is now on the whole app and taken from the bar's real published height, so it is also correctly 0 in the layout where the bar becomes a rail down the side.
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: focusable, announced, and impossible to scroll to, because a fixed element does not move when the page does. visibility: hidden puts it away, transitioned so the slide-out still runs.
Kept honest by the suite
uiAudit.js gained focusHasRoom() and focusablesReachable() — the two static halves, checked as the fix rather than the symptom since the symptom needs a keyboard and that file has no automation of its own. Both were fault-injected: removing the scroll padding reports the bar's full 125px, and reverting the sheet to a transform reports the Done button on the phone and nowhere else.
The unit suite also caught something on the way through: designTokens.test.js asserts every var() the stylesheet reaches for is defined, and --toastHeight is published from JavaScript. It is now on that file's explicit list of runtime-published tokens, with the reason.
Round eight — the pot list was pushing the table off the screen
The action bar is fixed over the felt, so its height is the felt's budget. It carries the side-pot breakdown, which drew one row per all-in with no ceiling.
Measured on a 360x640 phone:
bar
felt
five players, idle
151px (24%)
308px
six all-in
303px (47%)
156px
twelve all-in
411px (64%)
96px
At 96px the seats stop being a table. Five pairs collapse onto each other's centres, ten hang past the rail, a dozen labels sit 7/7 completely covered — 91 layout offenders on one screen.
Which end of the list to keep
Past four layers the bar now draws the main pot, one line for everything between, and the newest side pot. The two ends are not an arbitrary pick: 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.
The elided line carries the count and the money, so the visible rows still reconcile against the announced pot — $3,000 + $7,330 + $400 + $500 uncalled = the $11,230 on the badge. It fires only when it buys something: at exactly the cap the middle is one row, and swapping one row for one row loses an amount and gains a sentence.
Galaxy S8, twelve all-in
before
after
bar
411px (64%)
267px (42%)
felt
96px
192px
layout offenders
91
25
iPhone SE offenders
73
10
The sweep had never once seen an all-in
This is the part worth keeping. Every check in uiAudit.js asks whether an 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 it ever measured had a player all-in, and the one thing on the bar whose height depends on the table was empty in every single measurement.
There is now a fourth table 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() measures the fixed bottom band against the window. It fails over half — furniture larger than the content it serves is a shape fault whatever it is showing, and that line needs no taste to defend. It warns over 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.
Round nine — two empty rows were sizing the whole table
A 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 nothing in it 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 how far apart the whole table had to sit. Measured across 21 tables — seven viewports, six, nine and twelve handed — every single puck comes back shorter and six come back wider:
before
after
iPhone 14, 9 seats
78x88
78x63
iPhone 14, 12 seats
44x65, name clipped
66x55, not clipped
Pro Max, 9 seats
84x88
96x63
iPad, 12 seats
84x88
96x63
Galaxy S8, 12 seats
8 overlapping pairs, 1556px²
4 pairs, 87px²
The collapse is only safe because the cache key moved with it
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 these two rows, because while they always held their space they could never change a puck's height. Now they can.
So the signature names them, as booleans over the whole table rather than a list per seat: measuredSeatBox takes the tallest puck on the ring, so what matters is whether anybody has one, and a per-seat key would be equally correct while throwing the cache away on every single tap.
Measured on the running page: the ring settles by 24-26px when the blinds go in — 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.
Kept honest by the suite
uiAudit.js gains emptyDecoration() — nothing inside the felt 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.
Its one exemption is on the list because the check reported it, not because it was predicted: .betChipDisc 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.
seatPuckShape.test.js checks the cache 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.
A test that passed for the wrong reason
The signature joins names on a control character so "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.
Round ten — the felt grew with the window and the type never did
Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond — and the stack figure a dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px phone. 96px was the only ceiling a seat puck had.
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
Two things stop it costing more than it buys.
Every candidate width is a forced layout. Simply raising the ceiling took the layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that cannot use the wider widths must not measure them. The extra pass is offered only where the arithmetic already says the seats have room to spare, and a phone measures exactly the ten widths, in the same order, it measured before. Timed with the order alternated so neither run carries the warm-up: 10.7ms against 10.4ms on a phone — noise — and +3 to +6ms where the widths are actually used.
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 measured rather than chosen, since 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 instead of letting the phones quietly start paying.
The sweep is byte-identical to before this change: same six configurations, same 63 offenders, empty diff.
What the rail check is measuring, which is not the rail
Found this round, tried, and reverted — recorded 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 — a slacker question than the one the message "hangs Npx past the rail" claims to answer, because at the top and bottom of the ring the oval curves away from its own box.
Measured against the ellipse instead, 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 fixes that — 27 configurations better, 0 worse, 303px down to 15px — and then costs more than it fixes. Pulling the ring in far enough to clear the oval crowds the seats, and twelve-handed on a phone with everybody all-in it put seat 3's centre inside seat 4: the one trade the width search explicitly refuses, 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. Next round's work, not a patch at the end of this one.
Where it stands
before
after
failing configurations, on ground the sweep already covered
19
2
offenders on that same ground
98
3
audits run
216
320
audit checks
9
23
unit tests
728
833
Those first two rows are the like-for-like comparison, and they are unchanged from round seven: the raise pad on a 360px phone, where twelve pucks at the 44px touch minimum genuinely do not fit on the felt (3/7 and 2/7 — the search reports it rather than papering over it).
The sweep also grew this round, and the new ground is not clean. The all-in shape adds four failing configurations and 60 offenders — every one of them a pre-existing fault on a table nothing had ever looked at, and every one of them far worse before this round's fix than after it (91 → 25 on the Galaxy S8, 73 → 10 on the iPhone SE). What is left there is the seat ring itself: twelve seats on a short phone, now with 192px of felt instead of 96px, which is the next thing to work on rather than something this round introduced.
Every mechanism was checked by breaking it on purpose and restoring it: removing the settle pass puts 26 offenders back; dropping the badge from the footprint, 5; un-sizing the raise field is caught only by the step that now opens it; writing the sidebar's width out again instead of the token, growing the float to cover the chip's whole height, and taking the padding off the crowded ring are each caught by a test; and with both the float and the padding removed, the rewritten label check still reports the name 6px under the chip, which is the fault it exists for.
Round three was fault-injected the same way: reverting the aspect to 1, re-adding the floor, putting the cascade bug back, and leaving the width cap on for phones are each caught by their own test and only their own.
Round four's wording and the nesting it rests on were fault-injected as well: listing every eligible player again, moving the count behind the names, always naming who is in rather than the shorter side, dropping the title, and — in the calculator — refusing to merge equal-eligibility layers and letting folded players stay eligible. Each is caught by its own test.
Round five's structure was fault-injected too: stripping the headings back out of the summaries, putting the save/load/reset buttons back under Hands, and folding the waiting list back in behind a subhead. Each is caught by its own test, and the last one trips the grab-bag guard specifically.
Round six was fault-injected the same way: pricing the colour by chips-times-value (fails four tests, including the randomised agreement), leaving the counts behind, filtering the gain case away, and taking the new aria-label off (the audit then reports all five buttons).
Round seven's two checks were fault-injected as well: taking away the scroll padding reports the bar's full height, and putting the seat sheet away with a transform again reports its Done button on the phone and nowhere else.
Round ten's three faults were injected the same way — skipping the felt-span ceiling, measuring the longer span instead of the shorter, and never offering the wider widths at all — failing 2, 2 and 1 of its nine tests.
Round nine's four faults were injected the same way — dropping both flags from the signature, letting the count into the key instead of a boolean, counting only hand bets so a table playing antes is missed, and deleting the name separator — failing 3, 2, 1 and 1 of its eight tests; and with the old reservation restored, emptyDecoration() reports all seven empty pills.
The sweep itself is unchanged this round at 6 failing configurations and 63 offenders. All four table failures are the all-in shape, where every pill is filled and the collapse buys nothing; the ordinary tables this helps were already passing, so what moved is the room a name gets rather than whether anything was covered.
Round eight's cap was fault-injected three ways — ignoring it, letting the summary carry only the first elided pot's money, and appending the elision instead of keeping it in place — failing 6, 1 and 5 of its 11 tests respectively; and with the cap taken out, furnitureBudget() reports the original 64%.
Eight sidebar 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 own 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 Salesforce Lightning and Carbon both answer it the same way: 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.
panel
shut, before
shut, now
Table Setup
—
9 seats · $5/$10
Standings
—
Balanced / Off by $300 (green / amber)
Waiting list
—
2 waiting
Hands
—
3 hands
Chip Legend
—
5 colours · $1–$500
Table Notes
—
the note itself, ellipsised
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. A glance never says something the panel under it would contradict — it is read off the same state, never off the panel's DOM, which for a shut <details> isn't rendered at all; one test walks three buy-in figures asserting the glance's colour agrees with bankReconciliation's verdict and its number carries the same difference.
Three things this turned up that weren't the plan
Three handlers deliberately skip renderAll, because it would take the field out from under whoever is typing in it — and all three move something a glance reports. Without wiring them up, the note you had just typed was summarised by the note before it until the next tap landed. The notes textarea, the chip legend's value fields and commitSeatChange each call renderPanelGlances on their own instead.
The summary is announced as one button, so without a space between the label and the glance a screen reader was handed "Table Setup9 seats".
A comment I wrote was wrong and the code was right. I claimed re-pricing a chip moves the bank check. It doesn't: bankReconciliation asks chipsHeldBy, which is stackOf, which reads the seat's typed stack or the table's default and never opens the legend at all. The call still belongs there — for the legend's own glance, $1–$500 — and the justification is now the true one.
The audit check took three goes, and the sweep caught the second
glancesFitTheirRows v1 asked only whether a row had grown taller than a row with nothing on it. That 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 9px clear of a row that still measured exactly 44px.
v2 was wrong the other way, 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 #keyboardPanel 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.
A narrowing fix has to be shown not to have blinded the thing it quietened, so the four broken layouts 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.
Verification
853 unit tests pass (833 + 20 new). Four injected faults in the wording — a quiet row that never shuts up, "Balanced" over a table nobody bought into, the note's first line rather than its first line with something on it, and an unpriced chip counted as a dollar value — fail one test each.
Sweep: 6 failing configurations, 355 offenders — the identical set to round ten. The whole diff against it is 8 added lines, every one the new check reporting itself skipped on Quick Split, which has no sidebar.
Costs 0.08ms of a 4.79ms renderAll on a twelve-handed phone.
The rail lead from round ten, re-measured and closed
Round ten left the elliptical-rail conflict as the next round's work. 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 quoted before was the all-in shape, the extreme cell rather than a typical one.
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 having written down; the appearance is not worth a round. Closed rather than carried forward.
Seven rounds on this branch. Each starts from the same kind of finding: a number that was right about one thing being used for another.
---
## Round one — the phone on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which they cannot, so seven of nine finished past the rail with up to 17px of puck floating on the page. **That single fault was 96 of the 98 offenders the layout audit could find anywhere.**
The shortage was never in the table. The bar was 205px tall and 529 wide to hold three buttons and a status line, taking that height from the one element that needed height. **In landscape it becomes a rail down the right-hand side**, and the felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and a gap between them. It also puts Fold, Call and Raise under the right thumb rather than along the bottom edge, where a landscape grip has the palm.
Which layout the bar is in is declared by the stylesheet in a custom property and read back by `actionBar.js`, because the one thing that must not exist is a second copy of that media query written in JavaScript.
### The ring stopped predicting itself
`seatRingRadii` works out the widest ring whose outermost puck still finishes inside the rail, and it is right about a puck it measured *before* the width search settled and *before* a name wrapped or was shortened — each worth up to 15px of height arriving after the answer. Four viewports finished with the top seat 5-6px over.
`render.js` now checks the drawn result the way the audit judges it: if the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed again.
> A layout that predicts itself has to be right about the future. One that measures itself only has to be right about the present.
### Also
- **Names shorten at a word instead of being chopped mid-one.** "Mike from work" on a nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word, because "Mike from" looks cut off rather than shortened.
- **The blind badge is counted in what a seat needs** — it points away from the table, which was taken to mean it costs nothing between two seats: true of a straight rail, false of a ring. It also got shorter, 15px to 12, so clearing its own name costs every chair four pixels instead of nine.
- **Border-box, inherited, for the whole app.** Both elements that hadn't opted in went wrong the same afternoon: the rail overran the page's padding by 25px, and the raise pad's amount field pushed a horizontal scrollbar across a 640px phone.
- **The raise amount field is thumb-sized.** The rule that sizes fields for a finger names the settings panels and the seat form — the one field on the one surface built for thumbs was 36px on every phone in the list.
- `mobile-web-app-capable` alongside Apple's older name, which Chrome had been calling deprecated in this page's console on every load.
---
## Round two — the desktop
**The action bar was centred on the window.** The table is centred on its own column, and the sidebar takes 320px out of the right of that — so the two things a dealer is looking at sat **172px apart**, at 960px wide and at 1600px and everywhere in between, because the offset is half the sidebar and the sidebar is a constant. The bar also finished 80px inside the sidebar's column at 1280, painting over whichever panel happened to be at that height.
It leans away from the sidebar now, by the sidebar's own width, which comes out exact above and below the 1400px cap — measured at four widths, **0px off at all of them**. No `100vw` in it, which is the point: the bar's own centring comment warns that on a page with a scrollbar `100vw` is wider than the viewport.
**The dealer button was squeezing a name.** Its chip sits in the puck's top-right corner and the name kept clear with `padding-right: 22px` — which takes the chip's width off *every* line, and the chip is on one of them. At 1280x900 the name box is 78px, "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and broke as "Christop / her". Mid-word, no hyphen. The button moves every hand, so that is a player whose name reads properly for four hands and comes apart on the fifth.
A float indents only the lines it sits beside, so a name too long to fit next to the chip drops below it whole. Not on a crowded ring, though, and finding that out cost a sweep: a tight puck gives the name one line on purpose, and one line has nowhere to move to — the name landed on a second line the chip's lower half was still sitting on. So the float is for the ring with room for two lines and the padding for the one without.
### What the sweep learned
- **Text is measured where it was painted, not where its box is.** The seat-label check modelled "text kept out of somewhere" as padding, which is one way of doing it and not the only one. 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.** "Is under a badge" reads the same for a stack a dealer cannot read and for a rounded corner grazing the last digit. Probing the whole line turns the residual this sweep has carried for three rounds into `1/7 under SB`.
- **The sweep opens the raise pad**, which nothing ever had — it is part of the bar rather than an overlay, and it is both the tallest thing the bar holds and the only place a dealer types an amount.
---
---
## Round three — the felt was the wrong shape for a phone
A portrait phone got a square felt. A square box that is `width: 100%` of its column can never be taller than it is wide, so the vertical room the layout had already gone to some trouble to measure was published and then thrown away: **522px of room under a 366px table** on an iPhone 14, 638 under 406 on a Pro Max. Nothing was wrong with the arithmetic. The shape refused the answer.
The cost was seat names — 44px pucks with five of twelve clipped, "Christopher" and "Alexandra" both cut. The wide layout has been an oval lying down (0.72) all along; a phone is a portrait screen and this is a table seen from above, so `--feltAspect: 1.5` is the same decision made the other way up.
| | before | after |
| --- | --- | --- |
| iPhone 14, 12 seats | 44px puck, 5/12 clipped | 66px puck, **1/12** |
| iPhone 14, 5 seats | 54px puck, 3/5 clipped | 78px puck, **0/5** |
| Pixel 7, 12 seats | 44px, 5/12 | 72px, **0/12** |
| Pro Max, 12 seats | 44px, 5/12 | 72px, **0/12** |
1.6 and 1.7 measure byte-identical to each other on every phone, because by then the room is binding again — so 1.5 is where the sweep stops paying. The three smaller phones are room-bound at any cap and measured identical from 1.15 through 1.7, so this cannot regress them.
### The first version of this was a no-op
The rule went into `@media (max-width: 700px)`. Forty lines below, `@media (max-width: 900px)` set the same custom property back to `1`. Both queries match a phone, the declarations have identical specificity, and the later one wins — so `--feltAspect` was 1 on every phone on the site while the rule that was supposed to change it sat there reading perfectly. It was only caught by measuring the drawn page and finding the number hadn't moved.
Nothing in the suite could have caught that 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 "what does a 390px phone get?" is a question no single rule knows the answer to. The 900px rule is now a 701–900px range, and `feltShape.test.js` resolves the property the way the cascade does — every declaration on a selector, in source order, keeping the last one whose query matches — against five real phone sizes. 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.
### A floor that was added and then measured out again
`max-height: var(--feltRoomPx)` has no lower bound, so a tall enough bar presses the table flat. Flooring it at the desktop oval — `max(var(--feltRoomPx), 72cqw)` — looked obviously right, and went in.
It made things worse. Twelve players all in with nine side pots is the tallest the bar gets, **341px**, which at 360×640 leaves 142px of room. Floored to 242 the felt keeps its shape by putting **five of the twelve seats behind the action bar**; unfloored at 142 it is a flat table with all twelve visible. Same on an iPhone SE: three seats against none. A covered seat is the exact fault this whole measurement exists to prevent, and a flat table is only ugly.
The slit the floor defended against was never reachable either — it came from forcing `--feltRoomPx` to 10px by hand. The real floor is `FELT_FLOOR_PX` at 96, and the worst any of these phones actually publishes is 142, which lays out fine. A test now asserts the floor's *absence*, with the numbers, so it doesn't get re-added by someone reasoning the same way I did.
---
## Round four — who can win this pot
The pot breakdown listed every eligible player for every pot. Pot eligibility is **nested** — the main pot is everyone still live, and each side pot above it drops whoever was all-in below — so the main pot's line was every name at the table, and each side pot repeated that line minus one.
The lines too long to fit were exactly the ones nobody needed spelled out, and the short ones at the bottom were the side pots a dealer actually announces by name ("side pot is 80 between Ben and Carlos"). There was no count and no `title`, so a dealer reading `Alexandra…` could not tell whether three people or eleven were eligible — which is the number the showdown turns on, since side-pot hands are shown first.
Measured on an iPhone 14 before: **a single all-in five-handed already clipped the main pot**, and twelve-handed the first row asked for 521px of a 254px slot.
Each line now names whichever of "who is in" and "who is out" is shorter — nesting makes them complements, so that is never more than half the table — and leads with the count, because the line is right-aligned with an ellipsis and it is the tail that gets eaten.
| all-ins | before | after |
| --- | --- | --- |
| one, 5 handed | main pot clipped | nothing clipped |
| two, 6 handed | 2 rows clipped | nothing clipped |
| three, 9 handed | all 4 rows clipped | nothing clipped |
| twelve handed | 6 rows clipped | 5, and a cut row still opens `9 in` |
Same on a Galaxy S8. The result reads as `Main pot $360 · all 9`, `Side pot 1 $400 · 8 in, not Mike from work` — and it is self-checking, because the excluded names are exactly the players showing ALL IN on the felt, in stack order.
The wizard's copy of the same announcement **wraps** rather than truncating: it is a dialog that scrolls, and it is what the dealer reads out before anybody shows a hand, so nothing there is ever cut off. The full list is also on the `title` for anyone with a pointer — the extra, not the answer, since a tooltip is not a mechanism a keyboard reaches.
### The property underneath
`potCalculator.test.js` now proves the nesting across 20,000 random tables, and asserts on its own generator too: 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 believed. Both readings are silent, and the first version of this PR description would have claimed a property only half of which had been checked.
---
## Round five — the sidebar was five names and a cupboard
`Session` was a container word rather than a name. It 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 label**, which is the "don't mix apples and oranges" failure: one navigation layer carrying more than one logic.
It measured **1206px and 103 visible elements**, against 555 and 29 for the next biggest panel. "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 — and both destructive buttons were at the bottom of that scroll.
| | before | after |
| --- | --- | --- |
| biggest panel | Session, 1206px / 103 els | Chip Legend, 607px / 37 els |
| depth to Reset table | 1052px inside "Session" | 62px inside "Save & reset" |
| panels | 5 | 8 |
| collapsed height at rest | 588px desktop / 420 phone | 804 / 636 |
It is now **Standings**, **Waiting list**, **Hands** and **Save & reset**, each named for the thing a dealer came looking for, in the order of a night: set the table up, watch the money, fill the seats, look back at a hand — and last and apart, the two buttons that end it. Naming the panel for both jobs puts the danger on the label, read before the panel is opened rather than met at the end of a scroll. The four subheads that used to carry those names are gone, because the summary says it now.
The cost is honest and stated: **216px more collapsed summaries at rest**, three extra one-line labels. The working surface — felt and action bar — is untouched.
### The outline came out better than it went in
A `<summary>` is announced as a button, not a heading, so the sidebar's names — its whole navigation — were never reachable by heading. The only headings down there were the four subheads buried inside Session, and splitting it would have taken those away.
Every panel name is now an `<h2>` inside its summary, which the spec allows and both VoiceOver and NVDA expose. The outline is h1 page → h2 per panel → h3 for the two subheads that survive. Visually nothing moved: the heading gives up its margin, size, colour and spacing, and is `display: inline` so it doesn't push the disclosure triangle onto its own line.
### A test for a fault that broke nothing
`sidebarPanels.test.js` reads the `.cshtml`, because nothing here was broken — every control worked, every id resolved, no test could have failed. What was wrong was which panel a thing was in and what that panel was called. It asserts the names literally, that each is a heading, and that no panel goes back to being a grab-bag — counting *subjects* rather than size, since a panel needing its own headings to list its parts is holding more than one thing. Two subheads survive and the test names them: Settle up is the standings at the end of the night, Stacks in chips is the chip legend counted out.
---
## Round six — a chip colour was carrying the table's money
Deleting a chip colour was one click of an unlabelled 24px `×`, five of them in a column in a reference panel. It 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 counting real chips:
| | |
| --- | --- |
| on the table before | $26,874 |
| after one click on the `×` | $4,374 |
| **gone** | **$22,500 — 84%** |
| confirmations shown | **0** |
| recoverable | **no** |
Not recoverable because a colour added back is a *new* colour with a new id, while the counts stay keyed to the one that left — so "put it back" restores the row and none of the money. The orphaned counts stayed in the saved file too, money that nothing could price and nothing could show.
### Friction only where it is earned
A colour nobody has counted still deletes on one click. One that is holding money asks first, naming the amount, the chips and the seats, and saying plainly that Undo will put it back while adding the colour again will not. Removal now goes through `runUndoable`, and the counts leave with the colour so the file matches the screen.
That split is the point rather than an optimisation. A confirmation that fires every time is one nobody reads by the third night, and this tool already has four others competing for that attention — the effectiveness of a confirmation is a function of its rarity.
### The number in the warning is the number the table loses
A warning that overstates is worse than none, and the obvious arithmetic — 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. A randomised check over 2,000 tables asserts that what the warning claims is exactly what the table then does — and it 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.
### Every control says what it is
The `×` had no accessible name, so it was announced as a button and nothing else — five identical ones, each deleting a different colour. That is a WCAG 4.1.2 failure and, per WebAIM's 2025 sweep, among the most common on the web.
`uiAudit.js` gained `controlNames()`, which is the general form of it rather than a fix for the one button: every control needs a name containing a letter or a digit, resolved the way the accessibility tree resolves it — `aria-label`, `aria-labelledby`, a field's wrapping or pointing `<label>`, placeholder, text content, an image's `alt`, `title` — with anything under `aria-hidden` skipped rather than judged. A symbol is not a name: `×` is announced as "times". Taking the new label off again makes it report all five buttons, which is how it was checked.
---
## Round seven — the bar was sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden by something the page has put on top of it. The action bar is fixed over the bottom and is therefore not in the flow, so the browser does not know it is there: Tab to a control near the bottom and the browser scrolls it flush against the edge of the window — under the bar — and considers the job done.
Measured by tabbing the whole page:
| | before | after |
| --- | --- | --- |
| completely obscured, 390x844 phone | **16 stops** | 0 |
| partly obscured, same phone | 27 | 0 |
| completely obscured, 1280x900 desktop | 4 (under a toast) | 0 |
| phone on its side | 0 | 0 |
Clean against **2.4.12 (AAA)** as well, which allows no overlap at all.
### Three faults, three different answers
**`scroll-padding-bottom`** on the scroll container, sized from the bar's own published height plus the toast's, handles the case where the browser has to scroll to reach the control at all.
That is half of it, and it is the half that looks like the whole one. scroll-padding only applies when the browser scrolls; a control it already considers visible is one it will not scroll to, and a fixed overlay on top does not enter into that decision. Measured on the desktop: tabbing to Common rulings with a toast up left it **100% covered**, and `scrollIntoView({block: "nearest"})` moved nothing, because "nearest" was already satisfied. The first sweep had come back 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 exactly it — and when the page has no room left to scroll (26px available against the 94px wanted), the **toast gives way** instead. Sizing the page's bottom padding for the toast as well would have fixed that by making the page grow and shrink by 64px every time anything was announced, which is a worse thing to do to a dealer than the fault. On a phone, where there is room, the scroll happens and the toast stays up.
**The page's room to scroll past the bar** was `9rem` on the sidebar alone, which gave the column of panels somewhere to go and left the table's column — most of a desktop — with nothing. It is now on the whole app and taken from the bar's real published height, so it is also correctly 0 in the layout where the bar becomes a rail down the side.
**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: focusable, announced, and impossible to scroll to, because a fixed element does not move when the page does. `visibility: hidden` puts it away, transitioned so the slide-out still runs.
### Kept honest by the suite
`uiAudit.js` gained `focusHasRoom()` and `focusablesReachable()` — the two static halves, checked as the fix rather than the symptom since the symptom needs a keyboard and that file has no automation of its own. Both were fault-injected: removing the scroll padding reports the bar's full 125px, and reverting the sheet to a transform reports the Done button on the phone and nowhere else.
The unit suite also caught something on the way through: `designTokens.test.js` asserts every `var()` the stylesheet reaches for is defined, and `--toastHeight` is published from JavaScript. It is now on that file's explicit list of runtime-published tokens, with the reason.
## Round eight — the pot list was pushing the table off the screen
The action bar is fixed over the felt, so its height is the felt's budget. It carries the side-pot breakdown, which drew **one row per all-in with no ceiling**.
Measured on a 360x640 phone:
| | bar | felt |
| --- | --- | --- |
| five players, idle | 151px (24%) | 308px |
| six all-in | 303px (47%) | 156px |
| twelve all-in | **411px (64%)** | **96px** |
At 96px the seats stop being a table. Five pairs collapse onto each other's centres, ten hang past the rail, a dozen labels sit `7/7` completely covered — 91 layout offenders on one screen.
### Which end of the list to keep
Past four layers the bar now draws the main pot, one line for everything between, and the newest side pot. The two ends are not an arbitrary pick: 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.
The elided line carries the count and the money, so the visible rows still reconcile against the announced pot — `$3,000` + `$7,330` + `$400` + `$500` uncalled = the `$11,230` on the badge. It fires only when it buys something: at exactly the cap the middle is one row, and swapping one row for one row loses an amount and gains a sentence.
| Galaxy S8, twelve all-in | before | after |
| --- | --- | --- |
| bar | 411px (64%) | 267px (42%) |
| felt | 96px | **192px** |
| layout offenders | 91 | **25** |
| iPhone SE offenders | 73 | **10** |
### The sweep had never once seen an all-in
This is the part worth keeping. Every check in `uiAudit.js` asks whether an 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 it ever measured had a player all-in**, and the one thing on the bar whose height depends on the table was empty in every single measurement.
There is now a fourth table 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()` measures the fixed bottom band against the window. It **fails over half** — furniture larger than the content it serves is a shape fault whatever it is showing, and that line needs no taste to defend. It **warns over 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.
## Round nine — two empty rows were sizing the whole table
A 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 nothing in it 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 how far apart the whole table had to sit. Measured across 21 tables — seven viewports, six, nine and twelve handed — **every single puck comes back shorter and six come back wider**:
| | before | after |
| --- | --- | --- |
| iPhone 14, 9 seats | 78x88 | **78x63** |
| iPhone 14, 12 seats | 44x65, name clipped | **66x55**, not clipped |
| Pro Max, 9 seats | 84x88 | **96x63** |
| iPad, 12 seats | 84x88 | **96x63** |
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
### The collapse is only safe because the cache key moved with it
`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 these two rows, because while they always held their space they could never change a puck's height. Now they can.
So the signature names them, as **booleans over the whole table** rather than a list per seat: `measuredSeatBox` takes the tallest puck on the ring, so what matters is whether anybody has one, and a per-seat key would be equally correct while throwing the cache away on every single tap.
Measured on the running page: the ring settles by 24-26px when the blinds go in — 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.
### Kept honest by the suite
`uiAudit.js` gains **`emptyDecoration()`** — nothing inside the felt 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.
Its one exemption is on the list because the check reported it, not because it was predicted: `.betChipDisc` 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.
`seatPuckShape.test.js` checks the cache 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.
### A test that passed for the wrong reason
The signature joins names on a control character so `"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.
## Round ten — the felt grew with the window and the type never did
Nine-handed, the seats covered **19% of the felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond** — and the stack figure a dealer reads sat at **10px on a 2560px monitor**, the same 10px as on a 360px phone. 96px was the only ceiling a seat puck had.
| 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 |
Two things stop it costing more than it buys.
**Every candidate width is a forced layout.** Simply raising the ceiling took the layout pass from 24ms to **73ms** twelve-handed on a desktop, so a screen that cannot use the wider widths must not measure them. The extra pass is offered only where the arithmetic already says the seats have room to spare, and a phone measures exactly the ten widths, in the same order, it measured before. Timed with the order alternated so neither run carries the warm-up: **10.7ms against 10.4ms** on a phone — noise — and +3 to +6ms where the widths are actually used.
**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 measured rather than chosen, since 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 instead of letting the phones quietly start paying.
The sweep is **byte-identical** to before this change: same six configurations, same 63 offenders, empty diff.
### What the rail check is measuring, which is not the rail
Found this round, **tried, and reverted** — recorded 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** — a slacker question than the one the message "hangs Npx past the rail" claims to answer, because at the top and bottom of the ring the oval curves away from its own box.
Measured against the ellipse instead, 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 fixes that — 27 configurations better, 0 worse, 303px down to 15px — and then costs more than it fixes. Pulling the ring in far enough to clear the oval crowds the seats, and twelve-handed on a phone with everybody all-in it put **seat 3's centre inside seat 4**: the one trade the width search explicitly refuses, 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. Next round's work, not a patch at the end of this one.
## Where it stands
| | before | after |
| --- | --- | --- |
| failing configurations, on ground the sweep already covered | 19 | **2** |
| offenders on that same ground | 98 | **3** |
| audits run | 216 | 320 |
| audit checks | 9 | 23 |
| unit tests | 728 | 833 |
Those first two rows are the like-for-like comparison, and they are unchanged from round seven: the raise pad on a 360px phone, where twelve pucks at the 44px touch minimum genuinely do not fit on the felt (`3/7` and `2/7` — the search reports it rather than papering over it).
The sweep also grew this round, and the new ground is not clean. The all-in shape adds **four failing configurations and 60 offenders** — every one of them a pre-existing fault on a table nothing had ever looked at, and every one of them far worse before this round's fix than after it (91 → 25 on the Galaxy S8, 73 → 10 on the iPhone SE). What is left there is the seat ring itself: twelve seats on a short phone, now with 192px of felt instead of 96px, which is the next thing to work on rather than something this round introduced.
Every mechanism was checked by breaking it on purpose and restoring it: removing the settle pass puts 26 offenders back; dropping the badge from the footprint, 5; un-sizing the raise field is caught only by the step that now opens it; writing the sidebar's width out again instead of the token, growing the float to cover the chip's whole height, and taking the padding off the crowded ring are each caught by a test; and with both the float and the padding removed, the rewritten label check still reports the name 6px under the chip, which is the fault it exists for.
Round three was fault-injected the same way: reverting the aspect to 1, re-adding the floor, putting the cascade bug back, and leaving the width cap on for phones are each caught by their own test and only their own.
Round four's wording and the nesting it rests on were fault-injected as well: listing every eligible player again, moving the count behind the names, always naming who is in rather than the shorter side, dropping the title, and — in the calculator — refusing to merge equal-eligibility layers and letting folded players stay eligible. Each is caught by its own test.
Round five's structure was fault-injected too: stripping the headings back out of the summaries, putting the save/load/reset buttons back under Hands, and folding the waiting list back in behind a subhead. Each is caught by its own test, and the last one trips the grab-bag guard specifically.
Round six was fault-injected the same way: pricing the colour by chips-times-value (fails four tests, including the randomised agreement), leaving the counts behind, filtering the gain case away, and taking the new aria-label off (the audit then reports all five buttons).
Round seven's two checks were fault-injected as well: taking away the scroll padding reports the bar's full height, and putting the seat sheet away with a transform again reports its Done button on the phone and nowhere else.
Round ten's three faults were injected the same way — skipping the felt-span ceiling, measuring the longer span instead of the shorter, and never offering the wider widths at all — failing 2, 2 and 1 of its nine tests.
Round nine's four faults were injected the same way — dropping both flags from the signature, letting the count into the key instead of a boolean, counting only hand bets so a table playing antes is missed, and deleting the name separator — failing 3, 2, 1 and 1 of its eight tests; and with the old reservation restored, `emptyDecoration()` reports all seven empty pills.
The sweep itself is unchanged this round at 6 failing configurations and 63 offenders. All four table failures are the all-in shape, where every pill is filled and the collapse buys nothing; the ordinary tables this helps were already passing, so what moved is the room a name gets rather than whether anything was covered.
Round eight's cap was fault-injected three ways — ignoring it, letting the summary carry only the first elided pot's money, and appending the elision instead of keeping it in place — failing 6, 1 and 5 of its 11 tests respectively; and with the cap taken out, `furnitureBudget()` reports the original 64%.
---
## Eight sidebar 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 own 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 Salesforce Lightning and Carbon both answer it the same way: 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.
| panel | shut, before | shut, now |
| --- | --- | --- |
| Table Setup | — | `9 seats · $5/$10` |
| Standings | — | `Balanced` / `Off by $300` (green / amber) |
| Waiting list | — | `2 waiting` |
| Hands | — | `3 hands` |
| Chip Legend | — | `5 colours · $1–$500` |
| Table Notes | — | the note itself, ellipsised |
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. **A glance never says something the panel under it would contradict** — it is read off the same state, never off the panel's DOM, which for a shut `<details>` isn't rendered at all; one test walks three buy-in figures asserting the glance's colour agrees with `bankReconciliation`'s verdict and its number carries the same difference.
### Three things this turned up that weren't the plan
**Three handlers deliberately skip `renderAll`**, because it would take the field out from under whoever is typing in it — and all three move something a glance reports. Without wiring them up, the note you had just typed was summarised by the note *before* it until the next tap landed. The notes textarea, the chip legend's value fields and `commitSeatChange` each call `renderPanelGlances` on their own instead.
**The summary is announced as one button**, so without a space between the label and the glance a screen reader was handed `"Table Setup9 seats"`.
**A comment I wrote was wrong and the code was right.** I claimed re-pricing a chip moves the bank check. It doesn't: `bankReconciliation` asks `chipsHeldBy`, which is `stackOf`, which reads the seat's typed stack or the table's default and never opens the legend at all. The call still belongs there — for the legend's *own* glance, `$1–$500` — and the justification is now the true one.
### The audit check took three goes, and the sweep caught the second
`glancesFitTheirRows` v1 asked only whether a row had grown taller than a row with nothing on it. That 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 9px clear of a row that still measured exactly 44px.
v2 was wrong the other way, 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 `#keyboardPanel` 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.
A narrowing fix has to be shown not to have blinded the thing it quietened, so the four broken layouts 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.
### Verification
- **853 unit tests pass** (833 + 20 new). Four injected faults in the wording — a quiet row that never shuts up, "Balanced" over a table nobody bought into, the note's first line rather than its first line with something on it, and an unpriced chip counted as a dollar value — fail one test each.
- **Sweep: 6 failing configurations, 355 offenders — the identical set to round ten.** The whole diff against it is 8 added lines, every one the new check reporting itself skipped on Quick Split, which has no sidebar.
- Costs **0.08ms of a 4.79ms `renderAll`** on a twelve-handed phone.
### The rail lead from round ten, re-measured and closed
Round ten left the elliptical-rail conflict as the next round's work. 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 quoted before was the all-in shape, the extreme cell rather than a typical one.
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 having written down; the appearance is not worth a round. Closed rather than carried forward.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
A phone held sideways is 640x360: short of height and swimming in width. The layout
spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px
of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which
they cannot, so seven of nine finished past the rail with up to 17px of puck floating
on the page. That single fault was 96 of the 98 offenders the layout audit could find
anywhere.
No amount of shrinking fixes it, because the shortage was never in the table. The bar
is 205px tall and 529 wide to hold three buttons and a status line, and it was taking
that height from the one element that needed height. So in landscape it moves: the bar
becomes a rail down the right-hand side, the table takes the column beside it, and the
felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and
a gap between them. It also puts Fold, Call and Raise under the right thumb rather than
along the bottom edge, where a landscape grip has the palm.
Which layout the bar is in is declared by the stylesheet in a custom property and read
back by actionBar.js, because the one thing that must not exist is a second copy of
that media query written in JavaScript.
Then the ring stopped predicting itself. seatRingRadii works out the widest ring whose
outermost puck still finishes inside the rail, and it is right about a puck it measured
before the width search settled and before a name wrapped or was shortened — each worth
up to 15px of height arriving after the answer. Four viewports finished with the top
seat 5-6px over. render.js now checks the drawn result the way the audit judges it: if
the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed
again. A layout that predicts itself has to be right about the future; one that measures
itself only has to be right about the present.
Also in this round:
- Names shorten at a word instead of being chopped mid-one. "Mike from work" on a
nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word,
because "Mike from" looks cut off rather than shortened, and the whole name is on the
tooltip either way.
- The blind badge is counted in what a seat needs. It always points away from the table,
which was taken to mean it costs nothing between two seats — true of a straight rail
and false of a ring, where neighbours sit at different heights. Three tables had a
player's stack painted under the next player's SB or BB, and that badge outranks
everything else on the felt. It also got shorter, 15px to 12: clearing its own name at
5px of overhang instead of 9 hands every chair in the ring four pixels back, which at
640x360 is the difference between a 66px puck and a 48px one.
- Border-box, inherited, for the whole app rather than nine elements opting in one at a
time. Both elements that hadn't went wrong in the same afternoon: the rail overran the
page's padding by 25px and put Resolve Pot underneath itself, and the raise pad's
amount field pushed a horizontal scrollbar across a 640px phone.
- The raise amount field is thumb-sized. The rule that sizes fields for a finger names
the settings panels and the seat form, so the one field on the one surface built for
thumbs was 36px on every phone in the list. The sweep never saw it because it had no
step that opened the raise pad; it has one now, and that step also caught the rail
squeezing its own status line under the pad.
- mobile-web-app-capable alongside Apple's older name, which Chrome has been calling
deprecated in this page's console on every load.
The audit goes from 19 failing configurations and 98 offenders to 2 and 3, across 294
audits — thirteen viewports, three table shapes, a mouse and a thumb. What is left is
twelve players on a 360px phone with the raise pad open, where twelve pucks at the 44px
minimum genuinely do not fit on the felt; the search reports that rather than papering
over it. Each new mechanism was checked by breaking it on purpose: removing the settle
pass puts 26 offenders back, dropping the badge from the footprint 5, and un-sizing the
raise field is caught by the step that now opens it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
Two things that were wrong at every desktop width, both of them the same shape: a
number that was right about one thing being used for another.
The action bar was centred on the window. The table is centred on its own column, and
the sidebar takes 320px out of the right of that — so the two things a dealer is
actually looking at sat 172px apart, at 960px wide and at 1600px and everywhere in
between, because the offset is half the sidebar and the sidebar is a constant. The bar
also finished 80px inside the sidebar's column at 1280, painting over whichever panel
happened to be at that height. It leans away from the sidebar now, by the sidebar's own
width, which comes out exact above and below the 1400px cap — measured on the running
page at four widths, 0px off at all of them. No 100vw in it, which is the point: the
bar's own centring comment warns that on a page with a scrollbar 100vw is wider than
the viewport, and the offsets are exact without it.
The dealer button's chip sits in the puck's top-right corner and the name kept out from
under it with `padding-right: 22px`. That takes the chip's width off *every* line of the
name, and the chip is on one of them. Measured at 1280x900: the name box is 78px,
"Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and
broke as "Christop / her" — mid-word, no hyphen. The button moves every hand, so that is
a player whose name reads properly for four hands and comes apart on the fifth.
A float is what indents only the lines it sits beside. Line one gets 56px and the rest
get 78, so a name too long to fit beside the chip drops below it whole instead of being
broken to fit next to something that isn't there.
Not on a crowded ring, though, and finding that out cost a sweep. A tight puck gives the
name one line on purpose, and one line has nowhere to move to: with two lines granted
the name dropped onto a second line that the chip's lower half was still sitting on, and
the audit called it at every viewport in the list. So the float is for the ring with room
for two lines and the padding for the one without — two rules, matching two regimes the
puck already had.
The sweep also learned two things from this:
- Text is measured where it was painted, not where its box is. The seat-label check
modelled "text kept out of somewhere" as padding, which is one way of doing it and not
the only one; a float does it per line, and the check 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, not 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. Probing the whole line and counting turns the residual this sweep has carried for
three rounds into "1/7 under SB" — one probe of seven, one configuration, thumb only.
294 audits, 4 failing configurations, 5 offenders: twelve players on a 360px phone with
the raise pad open (3/7 and 2/7, pucks genuinely overlapping — the search reports it
rather than papering over it) and the 1/7 hairline above. 770 unit tests.
Fault-injected, all restored and re-verified: writing the sidebar's width out again
instead of the token, growing the float to cover the chip's whole height, and taking the
padding off the crowded ring are each caught by a test; and with both the float and the
padding removed the rewritten label check still reports the name 6px under the chip,
which is the fault it exists for.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps
changed title from Hold'em dealer: give the table the screen back when the phone is on its side to Hold'em dealer: a table that fits the phone sideways, and a bar that lines up with it2026-09-04 18:18:13 -06:00
jheaps
changed title from Hold'em dealer: a table that fits the phone sideways, and a bar that lines up with it to probe2026-09-04 21:34:11 -06:00
jheaps
changed title from probe to Hold'em dealer: a felt shaped for the phone it's on2026-09-04 21:35:01 -06:00
A portrait phone got a square felt, so the vertical room the layout had
already measured was published and then dropped: 522px of room under a
366px table on an iPhone 14, 638 under 406 on a Pro Max. The cost was
seat names — 44px pucks with five of twelve clipped.
--feltAspect: 1.5 spends it. Twelve seats at 390x844 go from a 44px puck
with five names clipped to 66px with one; five seats go from three
clipped to none; the Pixel 7's twelve-seat ring goes to zero. 1.6 and
1.7 measure byte-identical, so 1.5 is where the sweep stops paying. The
three smaller phones are room-bound at any cap and measured identical
from 1.15 to 1.7.
The first version of this was a no-op. The rule went into
@media (max-width: 700px), and @media (max-width: 900px) below it set
the same property back to 1 — both match a phone, same specificity,
later one wins. It measured as 1 on every phone while reading perfectly.
The 900px rule is now a 701-900px range, and feltShape.test.js resolves
--feltAspect through the media queries the way the cascade does so the
next one of these is caught.
Also tried and removed: a floor under the max-height, so a tall bar
couldn't press the table flat. Twelve all in with nine side pots takes
the bar to 341px, leaving 142px of room at 360x640; floored to 242 the
felt keeps its shape by putting five of twelve seats behind the bar,
unfloored it is flat with all twelve visible. The slit it defended
against came from forcing the custom property by hand. A test asserts
its absence with the numbers.
Audit: 4 failing configurations and 5 offenders down to 2 and 3, both
remaining ones pre-existing. 776 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
The pot breakdown listed every eligible player for every pot. Pot
eligibility is nested — the main pot is everyone still live and each side
pot above it drops whoever was all-in below — so the main pot's line was
every name at the table and each side pot repeated it minus one.
The lines too long to fit were exactly the ones nobody needed spelled
out, and the short ones at the bottom were the side pots a dealer
actually announces by name. Measured on an iPhone 14: a single all-in
five-handed already clipped the main pot, and twelve-handed the first row
wanted 521px of a 254px slot. There was no count and no title, so a
dealer reading "Alexandra…" could not tell whether three people or eleven
were eligible — the number the showdown turns on.
Each line now names whichever of "who is in" and "who is out" is shorter,
which nesting guarantees is never more than half the table, and leads
with the count because the line is right-aligned and it is the tail that
gets cut off. "all 9", "8 in, not Mike from work". On a phone this takes
one, two and three all-ins from every main-pot line clipped to nothing
clipped at all; twelve-handed goes 6 clipped rows to 5, and even a cut
row still opens with "9 in".
The wizard's copy of the same announcement wraps instead — it is a dialog
that scrolls, and it is what the dealer reads out before anybody shows a
hand — so nothing there is ever cut off. The full list is also on the
title for anyone with a pointer, which is the extra rather than the
answer, since a tooltip is not a mechanism a keyboard reaches.
potCalculator.test.js now proves the nesting across 20,000 random tables
and asserts on its own generator, so the loop cannot go vacuous. Audit
unchanged at 2 failing configurations and 3 offenders, both pre-existing.
787 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps
changed title from Hold'em dealer: a felt shaped for the phone it's on to Hold'em dealer: making the table and the bar fit the screen they are on2026-09-05 21:24:18 -06:00
"Session" was a container word rather than a name. It 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 label, which is a navigation
layer with more than one logic in it.
It measured 1206px and 103 visible elements, against 555 and 29 for the
next biggest, and "The table itself" sat 1052px down inside it. Reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get to it.
It is now Standings, Waiting list, Hands and Save & reset, each named for
the thing a dealer came looking for, in the order of a night: set the
table up, watch the money, fill the seats, look back at a hand — and,
last and apart, the two buttons that end it. Reset table goes from 1052px
inside a panel called Session to 62px inside one whose label says what it
does. The biggest panel drops from 1206px to 607px. The four subheads
that used to carry those names are gone, because the summary says it now.
Every panel name is also a heading. A <summary> is announced as a button,
so the sidebar's names — its whole navigation — were unreachable by
heading, and splitting Session would have removed the only four headings
down there. The outline is now h1 page, h2 per panel, h3 for the two
subheads that remain. Visually identical: the h2 gives up everything a
heading brings with it.
The cost is 216px more collapsed summaries at rest, three more one-line
labels. Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 795 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
Deleting a chip colour was one click of an unlabelled 24px button, and it
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. 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. The counts were left behind
too, so the saved file carried money nothing could price and nothing
could show.
Now: a colour nobody has counted still goes on one click, and one that is
holding money asks first, naming the amount, the chips and the seats, and
saying that Undo will put it back while adding the colour again will not.
Conditional on purpose — a confirmation that fires every time is one
nobody reads by the third night, and four others already compete for that
attention here. Removal goes through runUndoable, and the counts go with
the colour so the file matches the screen.
The number in that warning is the difference between pricing every seat
with the colour and without it, not chips times value. The obvious
arithmetic is wrong both ways: a seat with a typed amount is never priced
from its counts, and a seat whose chips all leave falls through to the
table's standing ante rather than to zero. A randomised check over 2,000
tables asserts the warning equals what the table then does, and it found
a third case — 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 × also had no accessible name, so uiAudit.js gained controlNames():
every control needs a name containing a letter or a digit, resolved the
way the accessibility tree resolves it. A symbol is not a name. Taking
the new aria-label off again makes it report all five buttons.
Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 805 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden
by something the page has put on top of it. The bar is fixed over the
bottom and is not in the flow, so the browser does not know it is there:
Tab to a control near the bottom and it is scrolled flush against the
edge of the window, under the bar, and the browser considers it done.
Tabbing the whole page measured 16 stops resting completely covered on a
390x844 phone and 27 more partly, plus four under a toast on the desktop.
It is now 0 and 0 on desktop, phone and a phone on its side — clean
against 2.4.12 (AAA) as well, which allows no overlap at all.
Three things were wrong and they needed different answers.
scroll-padding-bottom on the scroll container, sized from the bar's own
published height and the toast's, handles the case where the browser has
to scroll to reach the control. That is half of it, and the half that
looks like all of it: a control the browser already considers visible is
one it will not scroll to, and a fixed overlay on top 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. So a
focusin handler measures the overlap and scrolls by it, and when the page
has no room left (26px against 94px wanted) the toast gives way instead.
Sizing the page padding for the toast would have fixed that by making the
page grow and shrink 64px whenever anything was announced.
The page's room to scroll past the bar was 9rem on the sidebar alone,
which left the table's column — most of a desktop — with none. It is now
on the whole app and taken from the bar's real height, so it is also 0 in
the layout where the bar is a rail down the side.
And the seat editor's bottom sheet was put away with a transform, which
moves a thing without putting it away: its Done button sat at y=850 on an
844px screen, focusable, announced, and impossible to scroll to, because
a fixed element does not move when the page does. visibility:hidden puts
it away, transitioned so the slide-out still runs.
uiAudit.js gained focusHasRoom() and focusablesReachable() for the two
static halves. Audit unchanged at 2 failing configurations and 3
offenders, both pre-existing. 805 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
The bar is fixed over the table, so its height is the felt's budget — and it
carried a pot breakdown that drew one row per all-in with no ceiling. On a
360x640 phone: five players idle put the bar at 151px, six all-in at 303px,
twelve all-in at 411px. That is 64% of the screen, leaving 96px of felt to draw
twelve seats in, and at that size the seats stop being a table: five pairs
collapse onto each other's centres, ten hang past the rail and a dozen labels
sit completely covered.
Past four layers the bar now draws the main pot, one line for everything
between, and the newest side pot. The two ends are the live ones for the same
reason — side pots are paid from the newest 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 the two a dealer says out loud while the chips are stacked. The
middle is the rest of that count-down, wanted at showdown, which is the pot
wizard: same builder, no cap, in a dialog that scrolls. The elided line carries
the count and the money, so the visible rows still add to the announced pot.
Nothing in the sweep could see any of this, and the reason is worth keeping.
Every check asks whether an element is covered or clipped; none 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 it ever
measured had a player all-in, and the one thing on the bar whose height depends
on the table was empty in every measurement. There is now a fourth table shape,
twelve players shoved in for twelve different amounts, and furnitureBudget()
measures the fixed band against the window: failing over half of it, warning
over the 30% that persistent chrome is usually held to.
Galaxy S8, twelve all-in: bar 411px -> 267px, felt 96px -> 192px, 91 layout
offenders -> 25. iPhone SE: 73 -> 10. The rest is the seat ring on a short
phone, which the sweep can now see.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
A seat puck carries 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 nothing in it, 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 how far apart the whole
table had to sit. Across 21 tables — seven viewports, six, nine and twelve
handed — every single puck comes back shorter and six come back wider: an
iPhone 14 nine-handed 78x88 -> 78x63, twelve-handed 44x65 -> 66x55, which is the
one name in the set that was clipped and now is not.
The collapse is only safe because the layout cache moved with it.
resolveSeatLayout measures ten candidate widths, each a forced layout, against
seatShapeSignature() — which listed the names and the dealer seat, everything
that could wrap a name onto a second line. It did not mention these two rows,
because while they always held their space they could never change a puck's
height. Now they can, so the signature names them: booleans over the whole
table, because measuredSeatBox takes the tallest puck on the ring, and because
a per-seat key would be correct and would throw the cache away on every tap.
The ring settles by 24-26px when the blinds go in — and it settled by 23-25px
there before this round too, since the action bar changes height at that moment
and the felt is measured against it. The one place it moves is a place it
already moved.
uiAudit gains emptyDecoration(): nothing on the felt may paint a background or
a border with no content in it. Its one exemption is on the list because the
check reported it rather than because it was predicted — .betChipDisc is a
circle filled with the denomination colour of the chip beside it, so the colour
is the content. Three others were written from memory first and matched no
element on the page at all.
The sweep is unchanged at 6 failing configurations and 63 offenders. All four
table failures are the all-in shape, where every pill is filled and the
collapse buys nothing; the ordinary tables this helps were already passing, so
what moved is room a name has rather than whether anything was covered.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
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 — and the stack figure a
dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px
phone, well under every published floor for text meant to be read.
Two things stop this costing more than it buys.
Every candidate width is a forced layout. Simply raising the ceiling took the
layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that
cannot use the wider widths must not measure them: the extra pass is offered
only when the arithmetic already says the seats have room to spare, and a phone
measures exactly the ten widths, in the same order, that it measured before.
Timed with the order alternated so neither carries the warm-up: 10.7ms against
10.4ms on a phone, and +3 to +6ms where the widths are actually used.
And a wide puck on a narrow felt is worse than a small one. The first version
of this 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, reading 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 rather than 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 — and the test pins both ends.
Above the ordinary ceiling the puck takes the larger type as well, which is the
point of the extra width. Nine-handed: an iPad goes 96px/12px to 134px/15px, a
1440px desktop 96px/12px to 140px/15px, and the seats go from 8% of the felt to
12%. Every phone, at every seat count, is untouched.
The sweep is byte-identical to before this change: same six configurations,
same 63 offenders, empty diff.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
The sidebar is eight <details>, and every one measured exactly 60px shut with
nothing on it but its own 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. 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, 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. And a glance never says something the panel under
it would contradict: it is read off the same state, never off the panel's DOM,
which for a shut <details> isn't rendered at all.
Absolutely positioned rather than floated. A <summary> only draws its disclosure
triangle while it is display: list-item, so it cannot be a flex row; the float
that replaced it kept the row at 60px but aligned to the label's line box, and a
12px string in a box sized for 15px text rode 2.5px low on all six panels. Out of
flow is also the smaller promise — an absolutely positioned box cannot make its
row taller at all.
Three paths deliberately skip renderAll, because it would take the field out from
under whoever is typing in it, and all three move something a glance reports. The
notes textarea, the chip legend's value fields and commitSeatChange each call
renderPanelGlances on their own instead; without that the note you had just typed
was summarised by the note before it until the next tap landed. A space between
the label and the glance, too: the summary is announced as one button, and without
it a screen reader was given "Table Setup9 seats".
Costs 0.08ms of a 4.79ms renderAll on a twelve-handed phone. Four injected faults
in the wording are caught one test each.
glancesFitTheirRows took three goes. The first asked only whether a row had grown
taller than a bare one, and 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.
The second was caught by the sweep on its first clean run — 94 failing configs, 91
of the offenders this check's own: it takes the shortest row as its idea of a bare
one, and the Keyboard panel is display: none under pointer: coarse, so on every
viewport visited with a thumb the bar was a row that wasn't drawn, and all seven
real rows read as grown. Rows that aren't rendered aren't rows. The four broken
layouts are re-run with that panel hidden and each is still caught.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
jheaps
merged commit 14fb68dc04 into master2026-09-08 19:08:13 -06:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Seven rounds on this branch. Each starts from the same kind of finding: a number that was right about one thing being used for another.
Round one — the phone on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which they cannot, so seven of nine finished past the rail with up to 17px of puck floating on the page. That single fault was 96 of the 98 offenders the layout audit could find anywhere.
The shortage was never in the table. The bar was 205px tall and 529 wide to hold three buttons and a status line, taking that height from the one element that needed height. In landscape it becomes a rail down the right-hand side, and the felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and a gap between them. It also puts Fold, Call and Raise under the right thumb rather than along the bottom edge, where a landscape grip has the palm.
Which layout the bar is in is declared by the stylesheet in a custom property and read back by
actionBar.js, because the one thing that must not exist is a second copy of that media query written in JavaScript.The ring stopped predicting itself
seatRingRadiiworks out the widest ring whose outermost puck still finishes inside the rail, and it is right about a puck it measured before the width search settled and before a name wrapped or was shortened — each worth up to 15px of height arriving after the answer. Four viewports finished with the top seat 5-6px over.render.jsnow checks the drawn result the way the audit judges it: if the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed again.Also
mobile-web-app-capablealongside Apple's older name, which Chrome had been calling deprecated in this page's console on every load.Round two — the desktop
The action bar was centred on the window. The table is centred on its own column, and the sidebar takes 320px out of the right of that — so the two things a dealer is looking at sat 172px apart, at 960px wide and at 1600px and everywhere in between, because the offset is half the sidebar and the sidebar is a constant. The bar also finished 80px inside the sidebar's column at 1280, painting over whichever panel happened to be at that height.
It leans away from the sidebar now, by the sidebar's own width, which comes out exact above and below the 1400px cap — measured at four widths, 0px off at all of them. No
100vwin it, which is the point: the bar's own centring comment warns that on a page with a scrollbar100vwis wider than the viewport.The dealer button was squeezing a name. Its chip sits in the puck's top-right corner and the name kept clear with
padding-right: 22px— which takes the chip's width off every line, and the chip is on one of them. At 1280x900 the name box is 78px, "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and broke as "Christop / her". Mid-word, no hyphen. The button moves every hand, so that is a player whose name reads properly for four hands and comes apart on the fifth.A float indents only the lines it sits beside, so a name too long to fit next to the chip drops below it whole. Not on a crowded ring, though, and finding that out cost a sweep: a tight puck gives the name one line on purpose, and one line has nowhere to move to — the name landed on a second line the chip's lower half was still sitting on. So the float is for the ring with room for two lines and the padding for the one without.
What the sweep learned
1/7 under SB.Round three — the felt was the wrong shape for a phone
A portrait phone got a square felt. A square box that is
width: 100%of its column can never be taller than it is wide, so the vertical room the layout had already gone to some trouble to measure was published and then thrown away: 522px of room under a 366px table on an iPhone 14, 638 under 406 on a Pro Max. Nothing was wrong with the arithmetic. The shape refused the answer.The cost was seat names — 44px pucks with five of twelve clipped, "Christopher" and "Alexandra" both cut. The wide layout has been an oval lying down (0.72) all along; a phone is a portrait screen and this is a table seen from above, so
--feltAspect: 1.5is the same decision made the other way up.1.6 and 1.7 measure byte-identical to each other on every phone, because by then the room is binding again — so 1.5 is where the sweep stops paying. The three smaller phones are room-bound at any cap and measured identical from 1.15 through 1.7, so this cannot regress them.
The first version of this was a no-op
The rule went into
@media (max-width: 700px). Forty lines below,@media (max-width: 900px)set the same custom property back to1. Both queries match a phone, the declarations have identical specificity, and the later one wins — so--feltAspectwas 1 on every phone on the site while the rule that was supposed to change it sat there reading perfectly. It was only caught by measuring the drawn page and finding the number hadn't moved.Nothing in the suite could have caught that 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 "what does a 390px phone get?" is a question no single rule knows the answer to. The 900px rule is now a 701–900px range, and
feltShape.test.jsresolves the property the way the cascade does — every declaration on a selector, in source order, keeping the last one whose query matches — against five real phone sizes. 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.A floor that was added and then measured out again
max-height: var(--feltRoomPx)has no lower bound, so a tall enough bar presses the table flat. Flooring it at the desktop oval —max(var(--feltRoomPx), 72cqw)— looked obviously right, and went in.It made things worse. Twelve players all in with nine side pots is the tallest the bar gets, 341px, which at 360×640 leaves 142px of room. Floored to 242 the felt keeps its shape by putting five of the twelve seats behind the action bar; unfloored at 142 it is a flat table with all twelve visible. Same on an iPhone SE: three seats against none. A covered seat is the exact fault this whole measurement exists to prevent, and a flat table is only ugly.
The slit the floor defended against was never reachable either — it came from forcing
--feltRoomPxto 10px by hand. The real floor isFELT_FLOOR_PXat 96, and the worst any of these phones actually publishes is 142, which lays out fine. A test now asserts the floor's absence, with the numbers, so it doesn't get re-added by someone reasoning the same way I did.Round four — who can win this pot
The pot breakdown listed every eligible player for every pot. Pot eligibility is nested — the main pot is everyone still live, and each side pot above it drops whoever was all-in below — so the main pot's line was every name at the table, and each side pot repeated that line minus one.
The lines too long to fit were exactly the ones nobody needed spelled out, and the short ones at the bottom were the side pots a dealer actually announces by name ("side pot is 80 between Ben and Carlos"). There was no count and no
title, so a dealer readingAlexandra…could not tell whether three people or eleven were eligible — which is the number the showdown turns on, since side-pot hands are shown first.Measured on an iPhone 14 before: a single all-in five-handed already clipped the main pot, and twelve-handed the first row asked for 521px of a 254px slot.
Each line now names whichever of "who is in" and "who is out" is shorter — nesting makes them complements, so that is never more than half the table — and leads with the count, because the line is right-aligned with an ellipsis and it is the tail that gets eaten.
9 inSame on a Galaxy S8. The result reads as
Main pot $360 · all 9,Side pot 1 $400 · 8 in, not Mike from work— and it is self-checking, because the excluded names are exactly the players showing ALL IN on the felt, in stack order.The wizard's copy of the same announcement wraps rather than truncating: it is a dialog that scrolls, and it is what the dealer reads out before anybody shows a hand, so nothing there is ever cut off. The full list is also on the
titlefor anyone with a pointer — the extra, not the answer, since a tooltip is not a mechanism a keyboard reaches.The property underneath
potCalculator.test.jsnow proves the nesting across 20,000 random tables, and asserts on its own generator too: 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.lengthwent in — aSethas.size, sohere.length < below.lengthwas2 < 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 believed. Both readings are silent, and the first version of this PR description would have claimed a property only half of which had been checked.Round five — the sidebar was five names and a cupboard
Sessionwas a container word rather than a name. It 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 label, which is the "don't mix apples and oranges" failure: one navigation layer carrying more than one logic.It measured 1206px and 103 visible elements, against 555 and 29 for the next biggest panel. "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 — and both destructive buttons were at the bottom of that scroll.
It is now Standings, Waiting list, Hands and Save & reset, each named for the thing a dealer came looking for, in the order of a night: set the table up, watch the money, fill the seats, look back at a hand — and last and apart, the two buttons that end it. Naming the panel for both jobs puts the danger on the label, read before the panel is opened rather than met at the end of a scroll. The four subheads that used to carry those names are gone, because the summary says it now.
The cost is honest and stated: 216px more collapsed summaries at rest, three extra one-line labels. The working surface — felt and action bar — is untouched.
The outline came out better than it went in
A
<summary>is announced as a button, not a heading, so the sidebar's names — its whole navigation — were never reachable by heading. The only headings down there were the four subheads buried inside Session, and splitting it would have taken those away.Every panel name is now an
<h2>inside its summary, which the spec allows and both VoiceOver and NVDA expose. The outline is h1 page → h2 per panel → h3 for the two subheads that survive. Visually nothing moved: the heading gives up its margin, size, colour and spacing, and isdisplay: inlineso it doesn't push the disclosure triangle onto its own line.A test for a fault that broke nothing
sidebarPanels.test.jsreads the.cshtml, because nothing here was broken — every control worked, every id resolved, no test could have failed. What was wrong was which panel a thing was in and what that panel was called. It asserts the names literally, that each is a heading, and that no panel goes back to being a grab-bag — counting subjects rather than size, since a panel needing its own headings to list its parts is holding more than one thing. Two subheads survive and the test names them: Settle up is the standings at the end of the night, Stacks in chips is the chip legend counted out.Round six — a chip colour was carrying the table's money
Deleting a chip colour was one click of an unlabelled 24px
×, five of them in a column in a reference panel. It 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 counting real chips:
×Not recoverable because a colour added back is a new colour with a new id, while the counts stay keyed to the one that left — so "put it back" restores the row and none of the money. The orphaned counts stayed in the saved file too, money that nothing could price and nothing could show.
Friction only where it is earned
A colour nobody has counted still deletes on one click. One that is holding money asks first, naming the amount, the chips and the seats, and saying plainly that Undo will put it back while adding the colour again will not. Removal now goes through
runUndoable, and the counts leave with the colour so the file matches the screen.That split is the point rather than an optimisation. A confirmation that fires every time is one nobody reads by the third night, and this tool already has four others competing for that attention — the effectiveness of a confirmation is a function of its rarity.
The number in the warning is the number the table loses
A warning that overstates is worse than none, and the obvious arithmetic — chips times value — is wrong in both directions:
So the figure is the difference between pricing every seat with the colour and without it. A randomised check over 2,000 tables asserts that what the warning claims is exactly what the table then does — and it 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.
Every control says what it is
The
×had no accessible name, so it was announced as a button and nothing else — five identical ones, each deleting a different colour. That is a WCAG 4.1.2 failure and, per WebAIM's 2025 sweep, among the most common on the web.uiAudit.jsgainedcontrolNames(), which is the general form of it rather than a fix for the one button: every control needs a name containing a letter or a digit, resolved the way the accessibility tree resolves it —aria-label,aria-labelledby, a field's wrapping or pointing<label>, placeholder, text content, an image'salt,title— with anything underaria-hiddenskipped rather than judged. A symbol is not a name:×is announced as "times". Taking the new label off again makes it report all five buttons, which is how it was checked.Round seven — the bar was sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden by something the page has put on top of it. The action bar is fixed over the bottom and is therefore not in the flow, so the browser does not know it is there: Tab to a control near the bottom and the browser scrolls it flush against the edge of the window — under the bar — and considers the job done.
Measured by tabbing the whole page:
Clean against 2.4.12 (AAA) as well, which allows no overlap at all.
Three faults, three different answers
scroll-padding-bottomon the scroll container, sized from the bar's own published height plus the toast's, handles the case where the browser has to scroll to reach the control at all.That is half of it, and it is the half that looks like the whole one. scroll-padding only applies when the browser scrolls; a control it already considers visible is one it will not scroll to, and a fixed overlay on top does not enter into that decision. Measured on the desktop: tabbing to Common rulings with a toast up left it 100% covered, and
scrollIntoView({block: "nearest"})moved nothing, because "nearest" was already satisfied. The first sweep had come back clean only because 89 tab presses outlast an eight-second toast.So the other half is a
focusinhandler that measures the overlap and scrolls by exactly it — and when the page has no room left to scroll (26px available against the 94px wanted), the toast gives way instead. Sizing the page's bottom padding for the toast as well would have fixed that by making the page grow and shrink by 64px every time anything was announced, which is a worse thing to do to a dealer than the fault. On a phone, where there is room, the scroll happens and the toast stays up.The page's room to scroll past the bar was
9remon the sidebar alone, which gave the column of panels somewhere to go and left the table's column — most of a desktop — with nothing. It is now on the whole app and taken from the bar's real published height, so it is also correctly 0 in the layout where the bar becomes a rail down the side.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: focusable, announced, and impossible to scroll to, because a fixed element does not move when the page does.visibility: hiddenputs it away, transitioned so the slide-out still runs.Kept honest by the suite
uiAudit.jsgainedfocusHasRoom()andfocusablesReachable()— the two static halves, checked as the fix rather than the symptom since the symptom needs a keyboard and that file has no automation of its own. Both were fault-injected: removing the scroll padding reports the bar's full 125px, and reverting the sheet to a transform reports the Done button on the phone and nowhere else.The unit suite also caught something on the way through:
designTokens.test.jsasserts everyvar()the stylesheet reaches for is defined, and--toastHeightis published from JavaScript. It is now on that file's explicit list of runtime-published tokens, with the reason.Round eight — the pot list was pushing the table off the screen
The action bar is fixed over the felt, so its height is the felt's budget. It carries the side-pot breakdown, which drew one row per all-in with no ceiling.
Measured on a 360x640 phone:
At 96px the seats stop being a table. Five pairs collapse onto each other's centres, ten hang past the rail, a dozen labels sit
7/7completely covered — 91 layout offenders on one screen.Which end of the list to keep
Past four layers the bar now draws the main pot, one line for everything between, and the newest side pot. The two ends are not an arbitrary pick: 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.
The elided line carries the count and the money, so the visible rows still reconcile against the announced pot —
$3,000+$7,330+$400+$500uncalled = the$11,230on the badge. It fires only when it buys something: at exactly the cap the middle is one row, and swapping one row for one row loses an amount and gains a sentence.The sweep had never once seen an all-in
This is the part worth keeping. Every check in
uiAudit.jsasks whether an 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 withCallandCheck, so no table it ever measured had a player all-in, and the one thing on the bar whose height depends on the table was empty in every single measurement.There is now a fourth table 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-infor anyone who cannot cover.furnitureBudget()measures the fixed bottom band against the window. It fails over half — furniture larger than the content it serves is a shape fault whatever it is showing, and that line needs no taste to defend. It warns over 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.Round nine — two empty rows were sizing the whole table
A 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 nothing in it 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.
seatRingRadiisizes the ring from the tallest puck on it, so two rows nobody had filled were setting how far apart the whole table had to sit. Measured across 21 tables — seven viewports, six, nine and twelve handed — every single puck comes back shorter and six come back wider:The collapse is only safe because the cache key moved with it
resolveSeatLayoutmeasures ten candidate widths, each one a forced layout, and caches the result againstseatShapeSignature(). That signature listed the names and the dealer seat — everything that could wrap a name onto a second line. It did not mention these two rows, because while they always held their space they could never change a puck's height. Now they can.So the signature names them, as booleans over the whole table rather than a list per seat:
measuredSeatBoxtakes the tallest puck on the ring, so what matters is whether anybody has one, and a per-seat key would be equally correct while throwing the cache away on every single tap.Measured on the running page: the ring settles by 24-26px when the blinds go in — 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.
Kept honest by the suite
uiAudit.jsgainsemptyDecoration()— nothing inside the felt 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.Its one exemption is on the list because the check reported it, not because it was predicted:
.betChipDiscis 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.seatPuckShape.test.jschecks the cache 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.A test that passed for the wrong reason
The signature joins names on a control character so
"ann" + "abel"and"anna" + "bel"stay distinguishable. The first version of that test usedAnn/AbelagainstAnna/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.Round ten — the felt grew with the window and the type never did
Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond — and the stack figure a dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px phone. 96px was the only ceiling a seat puck had.
Two things stop it costing more than it buys.
Every candidate width is a forced layout. Simply raising the ceiling took the layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that cannot use the wider widths must not measure them. The extra pass is offered only where the arithmetic already says the seats have room to spare, and a phone measures exactly the ten widths, in the same order, it measured before. Timed with the order alternated so neither run carries the warm-up: 10.7ms against 10.4ms on a phone — noise — and +3 to +6ms where the widths are actually used.
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 measured rather than chosen, since 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 instead of letting the phones quietly start paying.
The sweep is byte-identical to before this change: same six configurations, same 63 offenders, empty diff.
What the rail check is measuring, which is not the rail
Found this round, tried, and reverted — recorded because the number is worth having and the fix is not a small one.
#pokerTableisborder-radius: 50%. Both the settle pass inrender.jsandseatsOnTheTable()in the audit compare a puck against the four edges of the table's bounding box — a slacker question than the one the message "hangs Npx past the rail" claims to answer, because at the top and bottom of the ring the oval curves away from its own box.Measured against the ellipse instead, 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 fixes that — 27 configurations better, 0 worse, 303px down to 15px — and then costs more than it fixes. Pulling the ring in far enough to clear the oval crowds the seats, and twelve-handed on a phone with everybody all-in it put seat 3's centre inside seat 4: the one trade the width search explicitly refuses, 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. Next round's work, not a patch at the end of this one.
Where it stands
Those first two rows are the like-for-like comparison, and they are unchanged from round seven: the raise pad on a 360px phone, where twelve pucks at the 44px touch minimum genuinely do not fit on the felt (
3/7and2/7— the search reports it rather than papering over it).The sweep also grew this round, and the new ground is not clean. The all-in shape adds four failing configurations and 60 offenders — every one of them a pre-existing fault on a table nothing had ever looked at, and every one of them far worse before this round's fix than after it (91 → 25 on the Galaxy S8, 73 → 10 on the iPhone SE). What is left there is the seat ring itself: twelve seats on a short phone, now with 192px of felt instead of 96px, which is the next thing to work on rather than something this round introduced.
Every mechanism was checked by breaking it on purpose and restoring it: removing the settle pass puts 26 offenders back; dropping the badge from the footprint, 5; un-sizing the raise field is caught only by the step that now opens it; writing the sidebar's width out again instead of the token, growing the float to cover the chip's whole height, and taking the padding off the crowded ring are each caught by a test; and with both the float and the padding removed, the rewritten label check still reports the name 6px under the chip, which is the fault it exists for.
Round three was fault-injected the same way: reverting the aspect to 1, re-adding the floor, putting the cascade bug back, and leaving the width cap on for phones are each caught by their own test and only their own.
Round four's wording and the nesting it rests on were fault-injected as well: listing every eligible player again, moving the count behind the names, always naming who is in rather than the shorter side, dropping the title, and — in the calculator — refusing to merge equal-eligibility layers and letting folded players stay eligible. Each is caught by its own test.
Round five's structure was fault-injected too: stripping the headings back out of the summaries, putting the save/load/reset buttons back under Hands, and folding the waiting list back in behind a subhead. Each is caught by its own test, and the last one trips the grab-bag guard specifically.
Round six was fault-injected the same way: pricing the colour by chips-times-value (fails four tests, including the randomised agreement), leaving the counts behind, filtering the gain case away, and taking the new aria-label off (the audit then reports all five buttons).
Round seven's two checks were fault-injected as well: taking away the scroll padding reports the bar's full height, and putting the seat sheet away with a transform again reports its Done button on the phone and nowhere else.
Round ten's three faults were injected the same way — skipping the felt-span ceiling, measuring the longer span instead of the shorter, and never offering the wider widths at all — failing 2, 2 and 1 of its nine tests.
Round nine's four faults were injected the same way — dropping both flags from the signature, letting the count into the key instead of a boolean, counting only hand bets so a table playing antes is missed, and deleting the name separator — failing 3, 2, 1 and 1 of its eight tests; and with the old reservation restored,
emptyDecoration()reports all seven empty pills.The sweep itself is unchanged this round at 6 failing configurations and 63 offenders. All four table failures are the all-in shape, where every pill is filled and the collapse buys nothing; the ordinary tables this helps were already passing, so what moved is the room a name gets rather than whether anything was covered.
Round eight's cap was fault-injected three ways — ignoring it, letting the summary carry only the first elided pot's money, and appending the elision instead of keeping it in place — failing 6, 1 and 5 of its 11 tests respectively; and with the cap taken out,
furnitureBudget()reports the original 64%.Eight sidebar 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 own 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 Salesforce Lightning and Carbon both answer it the same way: 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.
9 seats · $5/$10Balanced/Off by $300(green / amber)2 waiting3 hands5 colours · $1–$500Two rules hold it together and
panelGlance.test.jschecks 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. A glance never says something the panel under it would contradict — it is read off the same state, never off the panel's DOM, which for a shut<details>isn't rendered at all; one test walks three buy-in figures asserting the glance's colour agrees withbankReconciliation's verdict and its number carries the same difference.Three things this turned up that weren't the plan
Three handlers deliberately skip
renderAll, because it would take the field out from under whoever is typing in it — and all three move something a glance reports. Without wiring them up, the note you had just typed was summarised by the note before it until the next tap landed. The notes textarea, the chip legend's value fields andcommitSeatChangeeach callrenderPanelGlanceson their own instead.The summary is announced as one button, so without a space between the label and the glance a screen reader was handed
"Table Setup9 seats".A comment I wrote was wrong and the code was right. I claimed re-pricing a chip moves the bank check. It doesn't:
bankReconciliationaskschipsHeldBy, which isstackOf, which reads the seat's typed stack or the table's default and never opens the legend at all. The call still belongs there — for the legend's own glance,$1–$500— and the justification is now the true one.The audit check took three goes, and the sweep caught the second
glancesFitTheirRowsv1 asked only whether a row had grown taller than a row with nothing on it. That 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 9px clear of a row that still measured exactly 44px.v2 was wrong the other way, 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
#keyboardPanelisdisplay: noneunderpointer: 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.A narrowing fix has to be shown not to have blinded the thing it quietened, so the four broken layouts 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.
Verification
renderAllon a twelve-handed phone.The rail lead from round ten, re-measured and closed
Round ten left the elliptical-rail conflict as the next round's work. 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 quoted before was the all-in shape, the extreme cell rather than a typical one.
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 having written down; the appearance is not worth a round. Closed rather than carried forward.
🤖 Generated with Claude Code
https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
Hold'em dealer: give the table the screen back when the phone is on its sideto Hold'em dealer: a table that fits the phone sideways, and a bar that lines up with itHold'em dealer: a table that fits the phone sideways, and a bar that lines up with itto probeprobeto Hold'em dealer: a felt shaped for the phone it's on3932d74d47to13d704301dHold'em dealer: a felt shaped for the phone it's onto Hold'em dealer: making the table and the bar fit the screen they are onWCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden by something the page has put on top of it. The bar is fixed over the bottom and is not in the flow, so the browser does not know it is there: Tab to a control near the bottom and it is scrolled flush against the edge of the window, under the bar, and the browser considers it done. Tabbing the whole page measured 16 stops resting completely covered on a 390x844 phone and 27 more partly, plus four under a toast on the desktop. It is now 0 and 0 on desktop, phone and a phone on its side — clean against 2.4.12 (AAA) as well, which allows no overlap at all. Three things were wrong and they needed different answers. scroll-padding-bottom on the scroll container, sized from the bar's own published height and the toast's, handles the case where the browser has to scroll to reach the control. That is half of it, and the half that looks like all of it: a control the browser already considers visible is one it will not scroll to, and a fixed overlay on top 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. So a focusin handler measures the overlap and scrolls by it, and when the page has no room left (26px against 94px wanted) the toast gives way instead. Sizing the page padding for the toast would have fixed that by making the page grow and shrink 64px whenever anything was announced. The page's room to scroll past the bar was 9rem on the sidebar alone, which left the table's column — most of a desktop — with none. It is now on the whole app and taken from the bar's real height, so it is also 0 in the layout where the bar is a rail down the side. And the seat editor's bottom sheet was put away with a transform, which moves a thing without putting it away: its Done button sat at y=850 on an 844px screen, focusable, announced, and impossible to scroll to, because a fixed element does not move when the page does. visibility:hidden puts it away, transitioned so the slide-out still runs. uiAudit.js gained focusHasRoom() and focusablesReachable() for the two static halves. Audit unchanged at 2 failing configurations and 3 offenders, both pre-existing. 805 unit tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb