Hold'em dealer: making the table and the bar fit the screen they are on #119
Merged
jheaps
merged 11 commits from 2026-09-08 19:08:13 -06:00
polish/holdem-round-next into master
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4a1cb842cb |
Say on each shut sidebar panel the one thing you'd open it for
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 |
||
|
|
e7ddff2fed |
Let a large felt have larger seats, and the type that goes with them
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 |
||
|
|
f8ed4abc85 |
Stop two empty rows in every seat puck setting the size of the table
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 |
||
|
|
942919f3f7 |
Stop the side-pot list growing the action bar over the felt
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 |
||
|
|
02c2a034cb |
Stop the fixed bar and the toast 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 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
|
||
|
|
cd2db6346e |
Stop a chip colour taking the table's money with it, quietly
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 |
||
|
|
6eb97c8a35 |
Give the sidebar eight names instead of five 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 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 |
||
|
|
bd8ee11f77 |
Say who can win each pot in the fewest names that answer it
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 |
||
|
|
13d704301d |
Stand the felt up on a phone, and check which rule actually wins
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 |
||
|
|
7bd205a91a |
Line the bar up with the table, and stop the button squeezing a name
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 |
||
|
|
f07144acd8 |
Give the table the screen back when the phone is 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. 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 |