From f07144acd84fe739abbdfd50f4882d60ba49555c Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Fri, 4 Sep 2026 17:19:11 -0600 Subject: [PATCH 01/11] Give the table the screen back when the phone is on its side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .gitignore | 4 +- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 5 + JoshHeaps.Net/wwwroot/css/casino/holdem.css | 277 +++++++++++- .../wwwroot/js/casino/holdem/actionBar.js | 39 +- .../wwwroot/js/casino/holdem/render.js | 216 ++++++++-- tests/holdem/README.md | 33 +- tests/holdem/runUiAudit.js | 34 ++ tests/holdem/seatFootprint.test.js | 54 ++- tests/holdem/theRail.test.js | 402 ++++++++++++++++++ 9 files changed, 1027 insertions(+), 37 deletions(-) create mode 100644 tests/holdem/theRail.test.js diff --git a/.gitignore b/.gitignore index 6a4cfa1..de02e99 100644 --- a/.gitignore +++ b/.gitignore @@ -366,4 +366,6 @@ MigrationBackup/ FodyWeavers.xsd # Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/) -native/**/build/ \ No newline at end of file +native/**/build/ +# Playwright MCP scratch output +.playwright-mcp/ diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index b5951ae..731b53f 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -641,6 +641,11 @@ @* iOS reads none of the manifest's icons and only this. *@ + @* The standard name first, then Apple's older one. Chrome has been printing + " is deprecated" into this page's + console on every load; the replacement is the unprefixed name, and Safari still + wants the prefixed one, so both are here. *@ + diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 64df86f..e98541b 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -264,6 +264,35 @@ button, } } +/* ---------- Border-box, everywhere ---------- + A width is the width the thing is drawn at. Under the default `content-box` it isn't: + padding and border are added on afterwards, so `width: 192px` on a padded panel draws + 217, and every other rule that reserved room for it was 25px short in the same + direction. + + That is not hypothetical here. Nine elements in this file already opt in one at a + time — the seat puck's comment explains that it had to, because the search that sizes + it compares the number against distances on the felt — and the two that hadn't both + went wrong in the same afternoon: the action rail overran the page's padding by 25px + and put Resolve Pot underneath itself, and the raise pad's amount field at `100%` + plus 16px of padding pushed a horizontal scrollbar across a 640px phone. + + Opting in one element at a time leaves the trap set for the next one. This is the + inherited form, so a new element is border-box unless it says otherwise, and the + `inherit` means anything that does say otherwise still governs its own children. */ +*, +*::before, +*::after { + box-sizing: inherit; +} + +#holdemApp, +#actionBar, +#toastHost, +[id$="Overlay"] { + box-sizing: border-box; +} + #holdemApp { max-width: 1400px; margin: 0 auto; @@ -1092,13 +1121,32 @@ button, color: #f0d98c; } +/* The badge hangs off the puck's edge, and how far is not a taste question — it is + two measurements pulling in opposite directions. + + Downward, it has to stay off its own player's name. On a tight puck the name starts + 8px in, so a badge 15px tall had to hang 9px off to clear it, and 9px is what the + seat search has to charge every chair in the ring for (see seatFootprint in + render.js). Measured at 640x360, that charge is the difference between a 66px puck + and a 48px one — which is "Christopher, Mike, Alexandra" against "Christo…, Mike…, + Alexan…" on nine chairs. + + Upward, it has to stay off the neighbour. The badge is the thing painted on top of + everything else on the felt, so where it reaches past its own puck onto the next + one, what the dealer reads there is somebody else's stack. + + So the badge gets shorter instead of hanging further. Tight leading and no vertical + padding take it from 15px to 12, which clears the name at 5px of overhang instead of + 9 — and four pixels off what every seat is charged is a whole size step of puck back. + The label is the same 10px bold it always was; what went was the air around it. */ .blindBadge { display: none; position: absolute; - top: -9px; + top: -5px; font-size: var(--fs-2xs); font-weight: 700; - padding: var(--sp-0) var(--sp-1); + line-height: 1.1; + padding: 0 var(--sp-1); border-radius: var(--r-pill); z-index: 2; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); @@ -1114,7 +1162,7 @@ button, was, and the chip is a layer above it. See render.js for the measurement. */ .seat.seatBelowCentre .blindBadge { top: auto; - bottom: -9px; + bottom: -5px; } /* And the badge outranks the chips regardless, so a layout this doesn't anticipate @@ -2532,6 +2580,10 @@ button, /* ---------- Action bar ---------- */ #actionBar { + /* Which of the two shapes this bar is in, declared for actionBar.js to read back — + see isRail(). The layout is chosen by a media query, and the one thing that must + not happen is the same breakpoint written out a second time in JavaScript. */ + --actionBarLayout: bar; position: fixed; /* Centred with auto margins rather than 100vw + translate: on a phone with a scrollbar, 100vw is wider than the viewport and the overflow drags the whole @@ -4264,6 +4316,216 @@ body.hasActionBar #toastHost { } } +/* ---------- A phone on its side: the bar goes beside the table ---------- + + Everything above spends its effort dividing one column of height between a table and + a bar, on the screen that has the least height in the world. Measured at 640x360 + mid-hand, the split comes out: 70px of header, 85px of felt, 205px of bar. The felt + is 529x85. Two rows of seat pucks 55px tall have to fit in 85px of it, which they + cannot, so seven of the nine finish past the rail — up to 17px of puck floating on + the page. That is 96 of the 98 offenders the layout audit finds anywhere. + + No amount of shrinking fixes it, because the shortage is not in the table. A phone + on its side is 640x360: short of height and swimming in width. The bar is 205px tall + and 529 wide to hold three buttons and a status line, and it is taking that height + from the one element on the page that needs height. + + So it moves. In landscape the bar becomes a right-hand rail, the table takes the + whole column beside it, and the felt goes from 529x85 to roughly 420x270 — an actual + oval, with room for two rows of pucks and a gap between them. + + The rail is also where the hand already is. This is the layout every mapping and + drawing app uses on a rotated phone for the same reason, and it puts Fold/Call/Raise + under the right thumb rather than across the bottom edge where a landscape grip has + the palm. + + Scoped to phones. Above 900px the sidebar is a right-hand column already (see + #holdemLayout) and a rail would land on top of it; short desktop windows have height + to spare and don't have the problem. */ +@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px) { + :root { + /* Wide enough for "Raise to" and a four-figure amount side by side, and for a + 48px control to keep its own padding; never more than a third of the screen, + because the rest of it is the table. */ + --actionRailW: clamp(11.5rem, 30vw, 15rem); + } + + #actionBar { + --actionBarLayout: rail; + top: 0; + bottom: 0; + left: auto; + right: 0; + /* Border-box, so --actionRailW is the whole rail. Content-box made it 217px of + a 192px token, and everything that reserves room for it — the page's padding, + the seat sheet's right edge, the toast — was 25px short in the same direction: + Resolve Pot finished 13px underneath it. One number, one meaning. */ + box-sizing: border-box; + width: var(--actionRailW); + max-width: none; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); + /* The raise pad is the tallest thing that appears here. On the shortest screen + in the list it can outgrow the rail, and a control that has fallen off the + end of a fixed panel is unreachable rather than merely awkward. */ + overflow-y: auto; + overscroll-behavior: contain; + border-radius: var(--r-lg) 0 0 var(--r-lg); + border: 1px solid var(--line); + border-right: none; + padding: var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-right, 0px)) var(--sp-3) var(--sp-3); + box-shadow: -12px 0 34px rgba(0, 0, 0, 0.6); + } + + /* Anchored low: the stack reads top-to-bottom in the order it always has — status, + then the buttons — and sitting at the bottom of the rail puts Fold, Call and + Raise where the thumb rests rather than where the hand has to reach up. + + Done with a margin on the first child rather than `justify-content: flex-end`, + which is the same picture until the content outgrows the rail. Then flex-end + overflows the *start* edge, and a scroll container cannot scroll back past its + own start — the status line would be painted off the top and unreachable. A + margin pushes the same distance and collapses to nothing when there is none to + give, so the overflow lands at the end, where scrolling can reach it. Measured at + 640x360 the rail is 358px of content in 360px: it is one side pot away from + needing this. */ + #actionBar > :first-child { + margin-top: auto; + } + + /* And nothing in the rail gives up its own height to make room. A flex item shrinks + by default, so once the raise pad and the status line together outgrew the rail — + which they do at 640x360 the moment the amount field is thumb-sized — the status + line was squeezed under the pad and the undo pill came out two-fifths covered by + the Min button. Overflow is the honest outcome, and the scroll above can reach + it; a control drawn on top of another one cannot be reached at all. */ + #actionBar > * { + flex: none; + } + + /* The page gives up the width instead of the height. The bottom padding that was + clearing the bar has nothing left to clear — and 5rem of it on a 360px-tall + screen is a seventh of the window held empty for a bar that isn't there. */ + #holdemApp { + padding: var(--sp-2) calc(var(--actionRailW) + var(--sp-3)) var(--sp-2) var(--sp-3); + } + + /* Everything in the rail is a column now: three buttons stacked, and a status line + that was written to sit on one row of a 529px bar. */ + .actionButtons, + .dealPromptButtons { + flex-direction: column; + } + + /* This row already wraps, which is the right behaviour in a narrow column — it just + has to stop reserving two lines it no longer needs. That reservation exists so a + bar fixed to the bottom edge doesn't move its top edge under the dealer's thumb + as the text rewraps; a rail is anchored at the bottom the same way, but it is + 190px wide, where every one of these readings is on its own line regardless. */ + .actionStatus, + .actionStatus:has(.actionUndo) { + gap: var(--sp-1) var(--sp-2); + margin-bottom: 0; + min-height: 0; + } + + .actionUndo { + width: 100%; + } + + /* Four quick-raise buttons across 190px is 40px each. Two rows of two is 90px each, + which is a control rather than a target to aim at. */ + .raiseQuick { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .raiseEntry { + flex-wrap: wrap; + } + + .raiseInput { + flex: 1 0 100%; + } + + .raiseCancel, + .raiseConfirm { + flex: 1 1 0; + min-width: 0; + } + + /* Back to the middle of the screen. The old landscape rule pushed the toast to the + right edge to keep it off a full-width felt; that edge is the rail now, and the + felt no longer runs under it. */ + body.hasActionBar #toastHost, + #toastHost { + left: var(--sp-3); + right: calc(var(--actionRailW) + var(--sp-3)); + max-width: none; + bottom: var(--sp-3); + } + + /* The pot total comes back. It was hidden here because the badge took 58% of an + 85px felt and stood in the only band a bet chip could go in — on a felt three + times as tall it is the middle of the table again, which is where a dealer looks + for the pot. */ + #potBadge { + display: flex; + } + + /* And the ring goes back to being measured rather than held off the edges by hand. + The insets above exist to stop a flat felt's top seat putting its dealer button + through the header; with the felt this tall the ring has its own room, and the + 44px side inset was reserving a fifth of the width of a felt that is now the + narrower dimension. */ + #seatsLayer, + #betsLayer { + inset: 0; + } + + /* And the felt fills the box it is given. The 6% inset everywhere else is the strip + a seat puck straddles — half on the rail, half off it, the way a player sits at a + table. In this column that strip is 25px of a 409px width taken off both ends of + the widest dimension the table has left, and the ring does not want it: the seats + are held inside the rail here by measurement, so nothing is ever painted in it. + Measured, giving it back is 49px of felt, which is a puck size for every chair. */ + #pokerTable { + inset: 0; + } + + /* The links under the table are the last thing in the column, and on the old layout + they were simply behind the bar. There is room for them now, but not for the + margins of a desktop. */ + .tableHintActions { + margin: var(--sp-1) 0 0; + } + + /* The phone layout gives this row the full width, which puts it on a line of its + own — 36px of a 360px-tall screen for three buttons that need 217. Beside the + title instead, which is where they sit at every other size and which the header + has room for now that it is a column rather than the whole window. The wrap stays + as the backstop for a narrower screen than any in the list. */ + #holdemHeader { + flex-wrap: wrap; + } + + #holdemHeaderActions { + width: auto; + margin-left: auto; + } + + /* The seat editor comes up as a sheet from the bottom on a phone, full width. Full + width now runs under the rail, and it sits a layer above it, so it was covering + Fold, Call and Raise with a form. It stops at the rail instead — which also means + the dealer can still see the hand they are in while editing a chair. */ + #seatEditorPanel { + right: var(--actionRailW); + border-top-right-radius: var(--r-lg); + } +} + /* ---------- Keyboard focus ---------- Three focus rules existed for the whole tool, so tabbing through it relied on whatever ring the browser draws by default — which on these dark, custom-painted @@ -4326,7 +4588,14 @@ textarea:focus-visible { .seatEditorForm input:not([type="checkbox"]):not([type="color"]), .panelBody input:not([type="checkbox"]):not([type="color"]), .subPanelBody input:not([type="checkbox"]):not([type="color"]), - .panelBody select { + .panelBody select, + /* The raise amount, which this rule reached for the settings fields and missed — + it is in the action bar rather than in a panel or the seat form, so it is the + one field the list didn't name. Measured on a phone: 36px, on the one surface in + the tool that exists to be hit with a thumb, holding the one number that is the + size of somebody's bet. The audit never caught it either, because the sweep had + no step that opened the raise pad; it has one now. */ + .raiseInput { min-height: 44px; padding: var(--sp-2) var(--sp-2); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index f3bcbc9..d4a0792 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -32,9 +32,17 @@ const ActionBar = { const publish = () => { const visible = !host.hidden; - const height = visible ? host.getBoundingClientRect().height : 0; + const rail = this.isRail(host); + // On a phone held sideways the bar is a rail down the right-hand side, so it + // takes no height at all — and `--actionBarHeight` is what four other rules + // and the felt measurement all mean by "how much of the bottom is spoken + // for". Publishing the rail's height there would reserve the whole window. + const height = visible && !rail ? host.getBoundingClientRect().height : 0; document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`); - this.publishFeltRoom(height); + // What is under the felt is the bar in one layout and the two links in the + // other. Either way it is the same question — how much of the bottom is + // already spoken for — so it is the same argument. + this.publishFeltRoom(rail ? this.roomBelowFelt() : height); }; // Kept so anything that moves the table without touching the bar can ask for @@ -50,6 +58,33 @@ const ActionBar = { publish(); }, + // Is the bar a rail down the side rather than a bar across the bottom? + // + // Asked of the stylesheet rather than answered again here. The layout is chosen by + // a media query — orientation, height and width together — and a second copy of + // that query written in JavaScript is a copy that drifts: the first thing anyone + // changes is the breakpoint, in one of the two places. So the CSS declares which + // layout it picked in a custom property and this reads the answer back. + isRail(host) { + if (!host || typeof getComputedStyle !== "function") return false; + return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail"; + }, + + // What sits between the bottom of the felt and the bottom of the window when the + // bar is off to one side: the table hint and the row of links under it. Measured, + // because both come and go — the hint is empty mid-hand and the links wrap on the + // narrowest column. + // Taken as the distance from the bottom of the felt to the bottom of the section it + // sits in, which is a difference rather than a height — so it doesn't move when the + // felt's own height changes, and this can't chase itself around the screen. + roomBelowFelt() { + const wrap = document.getElementById("pokerTableWrap"); + const section = wrap && wrap.parentElement; + if (!section) return 0; + const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom; + return Math.max(0, Math.round(gap)); + }, + // Half a seat, the gap left under the lowest one, and the smallest felt worth // drawing. A seat is about 73px tall at every width — the puck is text-sized, not // table-sized — so the overhang is a constant rather than something read back off diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index b45a272..b5a8fd7 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -418,7 +418,7 @@ const Render = { // `bottom: -9px` in holdem.css. It points away from the table at both ends, so it // takes no room between two seats; what it must not do is reach *sideways*, which is // what seatFootprint.test.js holds the stylesheet to. - SEAT_BADGE_OVERHANG_PX: 9, + SEAT_BADGE_OVERHANG_PX: 5, // How much space two neighbouring seats need between their centres. // @@ -497,20 +497,33 @@ const Render = { // left corner onto whatever was beside it, and that is fixed where it was caused — // in the stylesheet, by moving the badges inside the puck's own width. // - // Its remaining overhang is vertical and costs nothing, because it always points - // *away* from the table: `top: -9px` on the upper arc, `bottom: -9px` on the lower - // one, so two seats facing each other across the felt wear their badges back to - // back. Two seats on the same arc wear them at the same height, offset by exactly - // as much as the pucks are. + // Its remaining overhang is vertical: `top: -9px` on the upper arc, `bottom: -9px` + // on the lower one, so it always points away from the table and two seats facing + // each other across the felt wear their badges back to back. // - // Adding it anyway was tried first, and it broke the case it was least expected to: - // a phone held sideways gives the ring 87px of height, two rows of 55px pucks leave - // 18px between them, and demanding 18px more for badges that point outward made a - // six-handed landscape table unsatisfiable at any width. Twelve-seat tables on three - // phones went the same way. The clearance below is what keeps rounded corners from - // reading as a touch. + // That is why it was left out of this for a while, and the reasoning has one hole + // in it. "Two seats on the same arc wear them at the same height" is true of a + // straight rail and false of a ring: neighbours on a curve sit at different heights, + // so the badge that points away from the table on one of them points at the corner + // of the other. Measured, three tables in the sweep had a player's stack painted + // under the next player's SB or BB — the badge outranks everything on the felt, so + // what a dealer reads there is the wrong number entirely. + // + // Counted once, not twice. Both boxes here are the same size, so the separation + // seatsCollide asks for is one box plus this — which is exactly right for two seats + // each wearing a badge on the same side, and generous for the far commoner case + // where only one of them is in the blinds at all. + // + // Adding it was tried once before and abandoned, because a phone held sideways gave + // the ring 87px of height and two rows of 55px pucks had 18px between them: nine + // more was unsatisfiable at any width. That felt is 205px tall now — see the rail in + // holdem.css — and the shortage that made this impossible is the one that block went + // and fixed. seatFootprint(puckBox) { - return puckBox && { width: puckBox.width, height: puckBox.height }; + return puckBox && { + width: puckBox.width, + height: puckBox.height + this.SEAT_BADGE_OVERHANG_PX, + }; }, applySeatWidth(layer, width) { @@ -1135,19 +1148,7 @@ const Render = { this.state.seats.forEach((player, index) => { const node = layer.children[index]; if (!node) return; - const pos = this.seatPosition(index, this.state.seatCount, aspect, radii); - node.style.left = `${pos.left}%`; - node.style.top = `${pos.top}%`; - - // Which side of the seat the pot is on, which is the side its bet chip - // arrives from — the chip sits on the same spoke, closer in. For a seat on - // the top of the ring that's from below; on the bottom, from above. - // - // The blind badge has to be on the other side or the chip lands on top of - // it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of - // the badge's 288 square pixels, and it sits a layer higher, so the marker - // telling the dealer who the big blind is was simply gone. - node.classList.toggle("seatBelowCentre", pos.top > 50); + this.placeSeat(node, index, aspect, radii); // The chair number is set once, in buildSeatNodes — a seat's index is the // one thing about it that cannot change while the node exists. @@ -1200,10 +1201,169 @@ const Render = { node.classList.toggle("seatSelected", index === this.selectedSeatIndex); }); + this.fitSeatNames(layer); + // The names have just changed how tall some of these pucks are, so this comes + // after them: a two-line name shortened to one line is 15px of height back. + const settled = this.settleSeatsOnTable(layer, aspect, radii); + // Kept so the board can ask for the chips to be laid out again if placing it // changes what is in the middle of the felt — see placeBoard(). - this.lastBetLayout = { aspect, radii }; - this.renderBets(aspect, radii); + this.lastBetLayout = { aspect, radii: settled }; + this.renderBets(aspect, settled); + }, + + // One seat, onto the ring. + placeSeat(node, index, aspect, radii) { + const pos = this.seatPosition(index, this.state.seatCount, aspect, radii); + node.style.left = `${pos.left}%`; + node.style.top = `${pos.top}%`; + + // Which side of the seat the pot is on, which is the side its bet chip arrives + // from — the chip sits on the same spoke, closer in. For a seat on the top of + // the ring that's from below; on the bottom, from above. + // + // The blind badge has to be on the other side or the chip lands on top of it. + // Measured on a 390px phone: the big blind's own $2 chip covered 196 of the + // badge's 288 square pixels, and it sits a layer higher, so the marker telling + // the dealer who the big blind is was simply gone. + node.classList.toggle("seatBelowCentre", pos.top > 50); + }, + + // Every seat on the table, checked against the table rather than predicted onto it. + // + // seatRingRadii works out the widest ring whose outermost puck still finishes inside + // the rail, and it is right about that — but it is right about a puck it measured + // before the ring moved, before the width search settled, and before a name was + // shortened or wrapped to a second line. Every one of those changes a puck's height + // by up to 15px after the arithmetic is done. Measured on the running page, four of + // the thirteen viewports finished with the top seat 5-6px past the rail: not a wrong + // formula, a stale input to a right one. + // + // So the ring is settled the way the layout audit judges it — by looking at where + // the pucks actually landed. If the worst of them hangs 6px over, the ring comes in + // by 6px and they are placed again. Two passes, because the first correction is + // exact and the second only exists in case moving them changed a height again. + // + // Never past RING_MIN_R. Below that the ring has collapsed into the pot, which is a + // worse table than one with a puck touching the rail — and a felt that small is the + // case the floor was put there for. + SEAT_SETTLE_PASSES: 2, + // The audit's own slack. Anything under a pixel is a rounding difference between + // two ways of measuring the same edge, and chasing it would move the ring forever. + SEAT_SETTLE_SLACK_PX: 1, + + settleSeatsOnTable(layer, aspect, radii) { + const table = typeof document !== "undefined" && document.getElementById("pokerTable"); + const box = this.ringBox(); + if (!table || !box || !box.width || !box.height) return radii; + + let current = radii; + for (let pass = 0; pass < this.SEAT_SETTLE_PASSES; pass++) { + const felt = table.getBoundingClientRect(); + if (!felt.width || !felt.height) return current; + + let overX = 0; + let overY = 0; + for (let index = 0; index < layer.childElementCount; index++) { + const seat = layer.children[index].getBoundingClientRect(); + if (!seat.width || !seat.height) continue; + overX = Math.max(overX, felt.left - seat.left, seat.right - felt.right); + overY = Math.max(overY, felt.top - seat.top, seat.bottom - felt.bottom); + } + if (overX <= this.SEAT_SETTLE_SLACK_PX && overY <= this.SEAT_SETTLE_SLACK_PX) return current; + + const next = { + rx: Math.max(this.RING_MIN_R, current.rx - (Math.max(0, overX) / box.width) * 100), + ry: Math.max(this.RING_MIN_R, current.ry - (Math.max(0, overY) / box.height) * 100), + }; + // The floor has taken over, and pulling in further is no longer something + // this can do. Stopping is the honest answer; looping would only spend + // another layout to be told the same thing. + if (next.rx === current.rx && next.ry === current.ry) return current; + + current = next; + for (let index = 0; index < layer.childElementCount; index++) { + this.placeSeat(layer.children[index], index, aspect, current); + } + } + return current; + }, + + // Names that don't fit are shortened at a word, not chopped in the middle of one. + // + // A puck is 34px of text on a nine-handed phone, and the label wraps to two lines + // and then ellipsises. What that produced for the four real names on the test table + // was "Christo…", "Mike / from…", "Alexan…" — and "Mike from work" is the one of + // those where the ellipsis is throwing away nothing the dealer needs. He is Mike. + // The rest of it is how this table tells him from the other Mike. + // + // So a name that overflows drops whole trailing words until it fits: "Mike from + // work" becomes "Mike from", then "Mike", and stops there with a real word rather + // than a fragment. A single word that still doesn't fit is left to the ellipsis it + // always had — there is no honest way to shorten "Christopher" that a dealer + // glancing across a table would read faster than "Christo…". + // + // Written as three passes over all the seats rather than a loop per seat: each pass + // writes to every puck and then reads back from every puck, so the browser lays the + // felt out three times whatever the table's size. Interleaved, nine seats would + // have cost nine layouts, and this runs on every render. + fitSeatNames(layer) { + if (!layer) return; + const labels = []; + this.state.seats.forEach((player, index) => { + const node = layer.children[index]; + const el = node && node.querySelector(".seatPuckName"); + if (!el || !el.textContent) return; + const full = (player.name || "").trim(); + // The tooltip is the whole name whether or not this one gets shortened: a + // label that wraps to two clamped lines was already hiding some of it. + el.title = full; + const words = full.split(/\s+/).filter(Boolean); + if (words.length > 1) labels.push({ el, words, kept: words.length }); + }); + if (!labels.length) return; + + for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) { + const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el)); + if (!shorten.length) return; + shorten.forEach(label => { + label.kept = this.dropAWord(label.words, label.kept); + label.el.textContent = label.words.slice(0, label.kept).join(" "); + }); + } + }, + + // Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike + // from", which is not a shorter name — it is a name that looks cut off, and it + // reads worse than the ellipsis it was meant to replace. Whereas "Mike" is just + // Mike, which is what the table calls him. + // + // Small and closed on purpose. This is not trying to understand names; it is the + // handful of English joining words that turn up in the way people label each other + // at a home game — "Mike from work", "Al with the hat", "Dee at the end". Anything + // it doesn't know stays, which is the safe direction to be wrong in: a name that + // keeps a word it could have dropped is merely long. + NAME_TAIL_WORDS: new Set(["from", "of", "the", "at", "in", "on", "with", "and", "&", + "de", "van", "von", "da", "di", "der", "la", "le"]), + + // One word off, plus any joining word that leaves behind. Never below one. + dropAWord(words, kept) { + let next = Math.max(1, kept - 1); + while (next > 1 && this.NAME_TAIL_WORDS.has(words[next - 1].toLowerCase())) next -= 1; + return next; + }, + + // A pass is a layout of the whole felt, and this runs on every render, so the budget + // is small. Three is more than the names actually need: "Mike from work" reaches + // "Mike" in one, because dropAWord takes the joining word with it, and a name of + // four plain words reaches its first in three. + SEAT_NAME_PASSES: 3, + + // Past the box in either direction. Both matter: the label clamps to two lines, so + // a name too long runs out of height, while a single word too wide for the puck + // runs out of width without ever reaching the second line. + overflowing(el) { + return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1; }, isRedSuit(card) { diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 4b97663..3b425c9 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -22,6 +22,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`. | `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations | | `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven | | `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot | +| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table | | `invariants.test.js` | 1000 randomised hands per property (see below) | ## The layout audit @@ -38,13 +39,23 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen no way to scroll to it, that no grid column has collapsed to nothing, that no text is painted outside its box, that no seat covers another seat's writing, and that no bet chip is hidden behind a seat or another chip. It -runs over the table, all five overlays and Quick Split, at thirteen viewports from a +runs over the table, the raise pad, all five overlays and Quick Split, at thirteen +viewports from a 360px phone to a 1600px desktop and the same phones on their side — each viewport twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand: the sweep posts blinds, deals and calls three times before it measures anything, so the bets layer has chips on it. It used to audit a table nobody had ever bet at, which is a third of what is on the felt during a hand left unlooked at. +The raise pad is newer than the rest and was worth every second it costs. It is part +of the bar rather than an overlay, so nothing here had a step that opened it, and it is +both the tallest thing the bar ever holds and the only place in the tool a dealer types +an amount. The first sweep that opened it found a 36px-tall field on every phone in the +list: the `hover: none` rule that sizes fields for a thumb names the settings panels and +the seat form, and the one field on the one surface built for thumbs was not in the +list. It also found the rail squeezing its own status line under the pad, which is the +kind of thing that only happens in the state nobody was looking at. + "Covered" and "out of view" are told apart before anything is hit-tested. An element clipped away by a panel that scrolls is not covered by whatever happens to be painted at that height, and reporting it that way is a diagnosis of something that isn't @@ -68,6 +79,26 @@ felt height. The seats follow a felt that changes size on their own, being place percentages of it; the chips anchored inside them do not, being placed in pixels. They were laid out for a felt 23px taller than the one they were painted on. +## What the sweep taught the layout + +For a long run of rounds the seat ring was *predicted* onto the table: work out the +widest ring whose outermost puck still finishes inside the rail, place the seats, and +trust it. The prediction is correct arithmetic and it was wrong on the page anyway, +because every input to it moves after it is taken — the width search settles on a +narrower puck, a name wraps to a second line or gets shortened out of one, the board +appears in the middle. Each of those is up to 15px of puck height arriving after the +answer. Ninety-six of the ninety-eight offenders this sweep could find were that. + +So render.js now finishes the way the audit judges it. After the seats are placed it +measures where they actually landed, and if the worst of them hangs 6px past the rail +the ring comes in by 6px and they are placed again — the same question this file asks, +asked by the code that has to get it right. Two passes, bounded, because each one is a +forced layout of the whole felt and it runs after every tap a dealer makes. + +That is the general shape of the lesson here: a layout that predicts itself has to be +right about the future, and a layout that measures itself only has to be right about +the present. + That last part, and two of those checks, are newer than this paragraph claiming them. `runAll()` did not include the contrast check at all, and touch targets sat behind an opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js index 9e5911a..063a981 100644 --- a/tests/holdem/runUiAudit.js +++ b/tests/holdem/runUiAudit.js @@ -166,6 +166,40 @@ async function main() { if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) }); table.warnings.forEach(w => warnings.push(`${label}: ${w}`)); + // The raise pad, which is part of the bar rather than an overlay and so + // was never once measured by any of this. It is the tallest thing the + // bar ever holds and the only place in the tool a dealer types an + // amount, and it had a 36px-tall field on every phone in the list: the + // `hover: none` rule that sizes fields for a thumb names the settings + // panels and the seat form, and the one field on the thumb surface was + // not in the list. Nothing here could see that, because nothing here + // ever opened it. + if (await page.evaluate(() => { + const btn = [...document.querySelectorAll("#actionBar button")] + .find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled); + if (!btn) return false; + btn.click(); + return true; + })) { + await page.waitForTimeout(200); + checks++; + const pad = await page.evaluate(() => LayoutAudit.runAll({ + occlusionSelector: "#actionBar button, #actionBar input", + })); + if (!pad.clean) { + failures.push({ + where: `raise pad · ${label} ${width}x${height}`, + offenders: pad.failed.flatMap(f => f.offenders), + }); + } + await page.evaluate(() => { + const cancel = [...document.querySelectorAll("#actionBar button")] + .find(b => /^Cancel/.test(b.textContent.trim())); + if (cancel) cancel.click(); + }); + await page.waitForTimeout(150); + } + // The overlays don't change with the table's shape, so they are only // opened over the half-full one — twice the viewports is enough time // spent already. diff --git a/tests/holdem/seatFootprint.test.js b/tests/holdem/seatFootprint.test.js index beeec27..e9ac9cd 100644 --- a/tests/holdem/seatFootprint.test.js +++ b/tests/holdem/seatFootprint.test.js @@ -165,10 +165,62 @@ describe("what the stylesheet has to keep holding up", () => { }); }); +describe("the blind badge counts", () => { + // It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px + // off the puck and always points away from the table — `top: -9px` on the upper arc, + // `bottom: -9px` on the lower — so two seats facing each other across the felt wear + // them back to back and it costs nothing. That much is true. + // + // What was also claimed is that two seats on the *same* arc "wear them at the same + // height, offset by exactly as much as the pucks are". True of a straight rail, + // false of a ring: neighbours on a curve sit at different heights, so the badge + // pointing away from the table on one of them points at the corner of the other. + // Measured on the running page, three tables in the sweep had a player's stack + // painted under the next player's SB or BB — and the badge outranks everything else + // on the felt, so what the dealer reads in that spot is the wrong number entirely. + test("a seat needs room for the badge it might be wearing", () => { + const puck = { width: 64, height: 40 }; + assert.deepEqual(Render.seatFootprint(puck), + { width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX }); + }); + + test("sideways it costs nothing, because the badge doesn't go sideways", () => { + // It sits inside the puck's own width — see "all three sit in the same place" + // above, which holds the stylesheet to it. + assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64); + }); + + test("the number here is the number the stylesheet draws", () => { + // Two files have to agree about one distance: holdem.css hangs the badge off the + // puck by it, render.js charges every chair in the ring for it. They are wired + // together by nothing but this test. Drift either way is silent — too small and + // badges land on neighbours again, too large and every puck on a nine-handed + // phone gives up a size step for clearance nothing needs. + const top = CSS.match(/\.blindBadge \{[^}]*?(? { + // seatsCollide is given one box and compares centres against it, so the + // separation it asks for is one puck plus one badge. That is exactly right for + // two seats on the same arc each wearing one, and generous for the far commoner + // case where only one of the two is in the blinds at all. Counting it on both + // sides is what made a landscape table unsatisfiable the first time this was + // tried, back when that felt was 87px tall. + const puck = { width: 64, height: 40 }; + const grew = Render.seatFootprint(puck).height - puck.height; + assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX); + assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX); + }); +}); + describe("with no browser to measure", () => { test("nothing here throws", () => { assert.equal(Render.measuredSeatBox(), null); assert.equal(Render.seatFootprint(null), null); - assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 }); }); }); diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js new file mode 100644 index 0000000..99256d3 --- /dev/null +++ b/tests/holdem/theRail.test.js @@ -0,0 +1,402 @@ +// A phone held sideways, and the three pieces of arithmetic that make the table fit on +// one. +// +// The layout audit is what proved this was broken and what proves it is fixed — it +// measures the drawn page, which is the only place a layout is really true. What is +// here is the part underneath that can be wrong on its own: which shape the bar is in, +// how much of the bottom that leaves spoken for, how a name is shortened when it will +// not fit, and how the ring is pulled back in when a puck lands past the rail. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const { ActionBar, Render } = require("./load"); + +const CSS = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); + +// --------------------------------------------------------------------------- + +describe("which shape the bar is in", () => { + // The layout is chosen by a media query — orientation, height and width together. + // The one thing that must not happen is a second copy of that query in JavaScript, + // because the first thing anybody changes is the breakpoint, in one of the two + // places. So the stylesheet declares which layout it picked and actionBar.js reads + // the answer back. + const withComputed = (value, run) => { + const host = { id: "actionBar" }; + global.getComputedStyle = el => ({ + getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""), + }); + try { return run(host); } finally { delete global.getComputedStyle; } + }; + + test("the stylesheet says, and this reads", () => { + assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true); + assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false); + }); + + test("anything it does not recognise is the bar it has always been", () => { + assert.equal(withComputed("", host => ActionBar.isRail(host)), false); + assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false); + }); + + test("and with no browser at all, likewise", () => { + assert.equal(ActionBar.isRail(null), false); + assert.equal(ActionBar.isRail({ id: "actionBar" }), false, + "no getComputedStyle to ask — this runs in the unit suite too"); + }); + + test("the stylesheet declares both halves of it", () => { + // If either of these goes, isRail() quietly answers "bar" forever and the felt + // is measured against a bar that is not underneath it. + assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/, + "the default, on the base rule"); + assert.match(CSS, /--actionBarLayout:\s*rail/, + "and the rail, inside the landscape query"); + }); + + test("the rail is scoped to a phone, not to any short window", () => { + // Above 900px the sidebar is already a right-hand column and a rail would land + // on top of it. + const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/); + assert.ok(query, "the rail lives in a landscape media query"); + assert.match(query[0], /max-width:\s*900px/); + assert.match(query[0], /max-height:\s*560px/); + }); +}); + +// --------------------------------------------------------------------------- + +describe("how much of the bottom is spoken for", () => { + // feltRoomFor asks one question — what is under the felt — and gets two different + // answers depending on the layout. Under the bar it is the bar. Under the rail it is + // the table hint and the two links, because the bar is off to the side. + const fakeSection = (wrapBottom, sectionBottom) => { + const wrap = { + getBoundingClientRect: () => ({ bottom: wrapBottom }), + parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) }, + }; + global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) }; + }; + + test.afterEach(() => { delete global.document; }); + + test("it is the gap under the felt, links and all", () => { + fakeSection(300, 342); + assert.equal(ActionBar.roomBelowFelt(), 42); + }); + + test("a difference, not a height — so it does not chase the felt it is sizing", () => { + // The wrap's own height comes from --feltRoomPx, which is what this feeds. Read + // as "how tall is everything under the felt" off the section's height it would + // move every time the felt did, and settle nowhere. + fakeSection(300, 342); + const first = ActionBar.roomBelowFelt(); + fakeSection(500, 542); // the felt grew by 200; what is under it did not + assert.equal(ActionBar.roomBelowFelt(), first); + }); + + test("nothing under the felt is nothing to reserve", () => { + fakeSection(300, 300); + assert.equal(ActionBar.roomBelowFelt(), 0); + }); + + test("and a section that measures shorter than its own child is not negative room", () => { + fakeSection(300, 288); + assert.equal(ActionBar.roomBelowFelt(), 0); + }); + + test("with no table on the page it asks for nothing", () => { + global.document = { getElementById: () => null }; + assert.equal(ActionBar.roomBelowFelt(), 0); + }); + + test("the felt gets the height the bar used to take", () => { + // The whole point, in one line. At 640x360 the bar was 205px of a 360px window + // and the felt got 85. Beside the table instead, the same window gives the felt + // everything but the header and the links. + const asABar = ActionBar.feltRoomFor(360, 205, 70); + const asARail = ActionBar.feltRoomFor(360, 30, 70); + assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`); + }); +}); + +// --------------------------------------------------------------------------- + +describe("a name that will not fit", () => { + // Shortened at a word, not chopped in the middle of one. A puck is 34px of text on + // a nine-handed phone, and what the ellipsis produced for the four names on the test + // table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing + // away something the dealer does not need: he is Mike, and the rest of it is how + // this table tells him from the other Mike. + // + // The label element is faked here rather than measured. What is under test is the + // ladder — which words go, in what order, and when it stops — and the browser's part + // is one boolean per pass. + const label = (text, fits) => { + const el = { + textContent: text, + title: "", + // The browser's answer, standing in for a real one: this name fits if + // `fits` says the version currently written to it does. + get scrollWidth() { return fits(this.textContent) ? 10 : 100; }, + get clientWidth() { return 10; }, + get scrollHeight() { return 10; }, + get clientHeight() { return 10; }, + querySelector: () => el, + }; + return el; + }; + + const layer = (labels) => ({ + children: labels, + childElementCount: labels.length, + }); + + const seatsNamed = (...names) => { + Render.state = { seats: names.map(name => ({ name })) }; + }; + + test.afterEach(() => { Render.state = null; }); + + test("trailing words go until it fits", () => { + const el = label("Christopher John Robinson", text => text === "Christopher John"); + seatsNamed("Christopher John Robinson"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Christopher John"); + }); + + test("and it will go all the way to the first word", () => { + const el = label("Christopher John Robinson", text => text === "Christopher"); + seatsNamed("Christopher John Robinson"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Christopher"); + }); + + test("a name never ends on a word a name cannot end on", () => { + // Dropping "work" off "Mike from work" leaves "Mike from", which is not a + // shorter name — it is a name that looks cut off, and it reads worse than the + // ellipsis this was meant to replace. He is Mike. + const el = label("Mike from work", () => false); + seatsNamed("Mike from work"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Mike"); + }); + + test("and it takes a run of them in one go", () => { + const el = label("Al with the hat", () => false); + seatsNamed("Al with the hat"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Al"); + }); + + test("but a joining word inside a name is nobody's business", () => { + // The list is closed and small on purpose: it is not trying to understand names, + // only the handful of English joining words people label each other with at a + // home game. Anything it doesn't know stays, which is the safe way to be wrong — + // a name that keeps a word it could have dropped is merely long. + const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak"); + seatsNamed("Katarzyna Nowak Kowalska"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Katarzyna Nowak"); + }); + + test("dropping never goes below the first word", () => { + // Every word after the first being a joining word is not a real name, but it is + // typeable, and it must not come out empty. + assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1); + assert.equal(Render.dropAWord(["from", "the"], 2), 1); + assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop"); + }); + + test("a single word is left to the ellipsis it always had", () => { + // There is no honest way to shorten "Christopher" that a dealer glancing across + // a table would read faster than "Christo…". + const el = label("Christopher", () => false); + seatsNamed("Christopher"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Christopher", "never cut mid-word here"); + }); + + test("a name that fits is not touched", () => { + const el = label("Mike from work", () => true); + seatsNamed("Mike from work"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Mike from work"); + }); + + test("the whole name is on the tooltip whether it was shortened or not", () => { + // A label clamped to two lines was already hiding some of it before any of this. + const shortened = label("Mike from work", text => text === "Mike"); + const untouched = label("Bob", () => true); + seatsNamed("Mike from work", "Bob"); + Render.fitSeatNames(layer([shortened, untouched])); + assert.equal(shortened.title, "Mike from work"); + assert.equal(untouched.title, "Bob"); + }); + + test("every seat is written to before any of them is measured", () => { + // Three passes over all the seats, not a loop per seat: each pass writes to + // every puck and then reads back from every puck, so the browser lays the felt + // out three times whatever the table's size. Interleaved, nine seats would cost + // nine layouts, and this runs on every render. + const reads = []; + const watched = (text, fits) => { + const el = label(text, fits); + Object.defineProperty(el, "scrollWidth", { + get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; }, + }); + return el; + }; + const a = watched("Christopher John Robinson", t => t === "Christopher"); + const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna"); + seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska"); + Render.fitSeatNames(layer([a, b])); + + // Both names are read at each length before either is written down again. + assert.deepEqual(reads.slice(0, 2), + ["Christopher John Robinson", "Katarzyna Nowak Kowalska"]); + assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]); + assert.equal(a.textContent, "Christopher"); + assert.equal(b.textContent, "Katarzyna"); + }); + + test("an empty chair has no name to shorten", () => { + const el = label("", () => false); + seatsNamed(""); + assert.doesNotThrow(() => Render.fitSeatNames(layer([el]))); + }); + + test("and with no layer there is nothing to do", () => { + assert.doesNotThrow(() => Render.fitSeatNames(null)); + }); +}); + +// --------------------------------------------------------------------------- + +describe("pulling the ring back onto the table", () => { + // seatRingRadii works out the widest ring whose outermost puck still finishes inside + // the rail, and it is right about that — but it is right about a puck it measured + // before the ring moved, before the width search settled, and before a name was + // shortened or wrapped to a second line. Each of those changes a puck's height by up + // to 15px after the arithmetic is done. + // + // Measured on the running page, four of the thirteen viewports finished with the top + // seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So + // the ring is settled the way the audit judges it, by looking at where the pucks + // actually landed. + // + // The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a + // percentage into style.left/top, which this reads back to work out where the puck + // would be — the same relationship the browser has, without a browser. + const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 }; + + const seat = (puckW, puckH, box) => ({ + style: { left: "", top: "" }, + classList: { toggle() {} }, + getBoundingClientRect() { + // Whatever percentage was last written, resolved against the ring box. + const cx = box.left + (parseFloat(this.style.left) / 100) * box.width; + const cy = box.top + (parseFloat(this.style.top) / 100) * box.height; + return { + left: cx - puckW / 2, right: cx + puckW / 2, + top: cy - puckH / 2, bottom: cy + puckH / 2, + width: puckW, height: puckH, + }; + }, + }); + + // The ring box the percentages resolve against, deliberately the same as the felt so + // the arithmetic in the test stays readable. + const ringBox = { ...felt }; + + const setUp = (count, puckW, puckH) => { + const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox)); + Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count }; + global.document = { getElementById: id => (id === "pokerTable" + ? { getBoundingClientRect: () => felt } + : { getBoundingClientRect: () => ringBox }) }; + // ringBox() reads #seatsLayer; the fake above hands the same box back for it. + return { children: seats, childElementCount: seats.length }; + }; + + const place = (layer, radii) => { + for (let i = 0; i < layer.childElementCount; i++) { + Render.placeSeat(layer.children[i], i, 1, radii); + } + }; + + const worstOverhang = layer => { + let over = 0; + for (let i = 0; i < layer.childElementCount; i++) { + const b = layer.children[i].getBoundingClientRect(); + over = Math.max(over, felt.left - b.left, b.right - felt.right, + felt.top - b.top, b.bottom - felt.bottom); + } + return over; + }; + + test.afterEach(() => { Render.state = null; delete global.document; }); + + test("a ring already inside the rail is left exactly where it is", () => { + const layer = setUp(6, 60, 50); + const radii = { rx: 30, ry: 30 }; + place(layer, radii); + assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii); + }); + + test("a puck past the rail brings the whole ring in", () => { + // 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is + // another 30, against a felt whose half-height is 200. Six pixels over, at the + // top and the bottom and both sides — which is the size the running page was + // actually wrong by. + const layer = setUp(4, 60, 60); + const radii = { rx: 44, ry: 44 }; + place(layer, radii); + assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix"); + + const settled = Render.settleSeatsOnTable(layer, 1, radii); + assert.ok(settled.ry < radii.ry, "the ring came in"); + assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX, + `still ${worstOverhang(layer).toFixed(1)}px over`); + }); + + test("it comes in by what it was over, not by a step it guessed", () => { + const layer = setUp(4, 60, 60); + const radii = { rx: 44, ry: 44 }; + place(layer, radii); + const over = worstOverhang(layer); + const settled = Render.settleSeatsOnTable(layer, 1, radii); + // Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the + // measurement, not a step size that would have to be tuned per screen. + assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2)); + assert.equal(settled.ry, 42.5); + }); + + test("and it stops at the floor rather than collapsing into the pot", () => { + // A puck taller than the felt has an overhang no ring can fix. Pulling in + // forever would put every seat on top of the pot badge, which is a worse table + // than one with a puck touching the rail. + const layer = setUp(4, 60, 600); + const radii = { rx: 40, ry: 40 }; + place(layer, radii); + const settled = Render.settleSeatsOnTable(layer, 1, radii); + assert.equal(settled.ry, Render.RING_MIN_R); + assert.ok(settled.ry > 0); + }); + + test("a table with nothing laid out yet is handed back untouched", () => { + Render.state = { seats: [], seatCount: 0 }; + global.document = { getElementById: () => null }; + const radii = { rx: 44, ry: 42 }; + assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii); + }); + + test("it is bounded — this runs after every tap a dealer makes", () => { + assert.ok(Render.SEAT_SETTLE_PASSES <= 2, + "each pass is a forced layout of the whole felt"); + assert.ok(Render.SEAT_NAME_PASSES <= 3); + }); +}); From 7bd205a91a83c437c57fffde0a67a16f14c8e021 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Fri, 4 Sep 2026 18:17:16 -0600 Subject: [PATCH 02/11] Line the bar up with the table, and stop the button squeezing a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 112 ++++++++++++++++++-- tests/holdem/README.md | 17 +++ tests/holdem/theRail.test.js | 98 +++++++++++++++++ tests/holdem/uiAudit.js | 59 ++++++++--- 4 files changed, 265 insertions(+), 21 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index e98541b..db0fa37 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -293,6 +293,13 @@ button, box-sizing: border-box; } +:root { + /* The sidebar's width, named because three rules need to agree about it: the grid + that draws the column, the action bar that has to stay out of it, and the seat + sheet's right edge. */ + --sidebarW: 320px; +} + #holdemApp { max-width: 1400px; margin: 0 auto; @@ -468,7 +475,7 @@ button, #holdemLayout { display: grid; - grid-template-columns: 1fr 320px; + grid-template-columns: 1fr var(--sidebarW); gap: var(--sp-5); align-items: start; } @@ -962,8 +969,15 @@ button, overflow: hidden; /* A single word longer than the puck has nothing to break on. `anywhere` was the first try and it gave "Christo / pher", which reads as a rendering fault; with - `break-word` the browser only breaks a word that cannot fit on a line of its - own, and `hyphens` puts a hyphen in when it does. */ + `break-word` the browser only breaks a word that cannot fit on a line of its own. + + `hyphens` is here in hope rather than in trust. It is meant to put a hyphen in at + that break, and where a browser has a hyphenation dictionary it does — but that is + not a thing to build a layout on. Measured directly: a 56px box with `hyphens: + auto` and nothing else does not hyphenate "Christopher", it overflows, which means + the dictionary is simply absent and the property is inert. So nothing above this + line depends on it; where it works it is a free improvement to a break that is + already legible without it. */ overflow-wrap: break-word; hyphens: auto; line-height: 1.2; @@ -975,14 +989,66 @@ button, margin-top: var(--sp-2); } -/* The button's chip sits in this puck's top-right corner, on a layer above the text. - Reserving its width means a long name ends in an ellipsis before it reaches the - chip, rather than disappearing underneath one — on the one seat at the table a - dealer looks at most. */ +/* The button's chip sits in this puck's top-right corner, on a layer above the text, so + the name has to keep out from under it. + + It used to do that with `padding-right: 22px`, which 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 wide, "Christopher" is 66px and fits, and on the seat holding the button it + was cut to 56px and broke as "Christop / her". Mid-word, and no hyphen (see below). + The button moves every hand, so that is a player whose name reads properly for four + hands and comes apart on the fifth. On a Pixel 7 it went to three lines and the clamp + ate the end of it. + + A float is what indents only the lines it sits beside, which is the whole of the + problem. 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. A short name still sits up beside the chip exactly as it did. + + The padding stays for the crowded ring, and it has to. A tight puck gives the name one + line on purpose — see `.seatsTight .seatPuckName` — and one line has nowhere to move + to, so the only way to keep it off the chip there is to make the line shorter. Trying + the float on both cost a whole sweep to find out: with two lines granted, the name + dropped below the float 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 that has room for two lines, and the padding is for the + one that doesn't. Two rules, matching the two regimes the puck already had. */ .seat.seatHasButton .seatPuckName { padding-right: 22px; } +/* Two things have to go with the float. It does nothing inside `display: -webkit-box`, + so the clamp is replaced by the same two lines held as a max-height. And + `overflow-wrap: break-word` has to stand down, because it is what stops the float + working: it breaks a word to fit the short line rather than letting it move past the + float to the long one, which is the whole manoeuvre. + + Measured, at the name-box widths a roomy button seat gets: + + 78px (desktop, iPad) "Christopher" was 2 lines of 54+12 now 1 line of 66 + 78px (desktop, iPad) "Katarzyna" was 2 lines of 49+6 now 1 line of 56 + + The float is one line tall, which is the height it has to be: tall enough to cover the + chip's reach into the name — the chip's bottom edge is 26px down and the name's first + line ends at 27.4 — and no taller, because a float that spills into the second line + indents that one too and puts the whole problem back. */ +#seatsLayer:not(.seatsTight) .seat.seatHasButton .seatPuckName { + padding-right: 0; + display: block; + max-height: 2.4em; + overflow: hidden; + overflow-wrap: normal; +} + +#seatsLayer:not(.seatsTight) .seat.seatHasButton .seatPuckName::before { + content: ""; + float: right; + /* The chip, plus the 3px it is inset by. */ + width: 25px; + height: 1.2em; +} + .seatPuckCards { font-size: var(--fs-xs); font-weight: 700; @@ -2611,6 +2677,38 @@ body.hasActionBar #holdemSidebar { padding-bottom: 9rem; } +/* ---------- The bar lines up with the table ---------- + It 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 were 172px apart, at 960px wide and at 1600px wide and at every width 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. + + Centred between the page's left padding and the table column's right edge instead. + Worth doing in offsets rather than a width: the auto margins still centre the bar, so + it stays 720px at most and centred inside whatever room this leaves it. + + The arithmetic comes out exact at every width, above and below the 1400px cap, which + is worth showing because it looks like it shouldn't: + + bar centre = (--sp-6 + 100vw - (--sidebarW + --sp-5 + --sp-6)) / 2 + = (100vw - 344) / 2 + table centre = appLeft + --sp-6 + (contentWidth - (--sidebarW + --sp-5)) / 2 + + and under the cap appLeft is 0 with content = 100vw - 64, while over it appLeft is + (100vw - 1400)/2 with content fixed at 1336. Both reduce to (100vw - 344) / 2. + + No 100vw anywhere, which is the point — on a page with a scrollbar 100vw is wider + than the viewport, and that is exactly the trap the bar's own centring comment + warns about. */ +@media (min-width: 901px) { + #actionBar { + left: var(--sp-6); + right: calc(var(--sidebarW) + var(--sp-5) + var(--sp-6)); + } +} + .actionStatus { display: flex; flex-wrap: wrap; diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 3b425c9..8df8b08 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -62,6 +62,23 @@ at that height, and reporting it that way is a diagnosis of something that isn't happening: the pot wizard's third winner, below the fold in a list, came back for four viewports running as "fully covered by button Confirm Winner(s)". +Text is measured where it was painted, not where its box is. The two are different +things and the difference is not academic: a name is centred, so its box is wider than +its ink, and a name kept clear of the dealer's chip by a float is out of the way on the +line beside the chip and full width on the line below. The check used to model that as +"box minus padding", which is one way of keeping text out of somewhere and not the only +one — it called every seat in the sweep an offender while the text sat exactly where it +was meant to. Range rects ask the question directly, so padding, floats, centring, +wrapping and a short label in a wide box all come out right without any of them being +modelled. + +Offenders say how much is covered rather than that something is. "Is under a badge" +reads the same for a stack a dealer cannot read and for a badge's rounded corner +grazing the last digit, and the difference decides whether it is worth fixing. Probing +the whole line and counting turned the residual this sweep has carried for three rounds +into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context +only. + More than one table shape is also newer than it sounds. The sweep ran on one — five players at a nine-handed table — for its whole life, and how much room the felt has depends on how many people are sitting down far more than on anything a viewport can diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js index 99256d3..4e5c9de 100644 --- a/tests/holdem/theRail.test.js +++ b/tests/holdem/theRail.test.js @@ -69,6 +69,104 @@ describe("which shape the bar is in", () => { // --------------------------------------------------------------------------- +describe("the bar lines up with the table", () => { + // On a desktop the bar was centred on the window and the table on its own column, + // and the sidebar takes 320px out of the right of that — so the two things a dealer + // is looking at were 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 rule that fixes it is four numbers that have to keep agreeing with four other + // numbers, none of them anywhere near each other in the file. That is what this + // block is for: the arithmetic is checked on the running page by measurement (bar + // centre against table-column centre, which came out 0px off at every width), and + // the numbers it depends on are checked here so they cannot drift apart afterwards. + const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/); + + test("there is a rule, and it is scoped above the one-column breakpoint", () => { + // Below 901px the sidebar is not a column at all — it is stacked under the table + // — so there is nothing to lean away from and the bar is centred as it was. + assert.ok(rule, "no min-width: 901px rule for #actionBar"); + assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/, + "the one-column layout should start exactly where this rule stops"); + }); + + test("it leans away from the sidebar by the sidebar's own width", () => { + assert.match(rule[1], /left:\s*var\(--sp-6\)/); + assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/); + }); + + test("and the sidebar's width is one number, not two", () => { + // The grid draws the column and the bar leans away from it. Written out twice, + // the day somebody widens the sidebar is the day the bar goes crooked and no + // test says so. + assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared"); + assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/, + "the grid should draw the column from the token"); + }); + + test("no 100vw anywhere in it", () => { + // The bar's own centring comment warns about this: on a page with a scrollbar + // 100vw is wider than the viewport, and the overflow drags the table sideways. + // The offsets are exact without it — see the derivation in the stylesheet. + assert.equal(/100vw/.test(rule[1]), false, rule[1]); + }); +}); + +// --------------------------------------------------------------------------- + +describe("the button seat's name", () => { + // The dealer button's chip sits in the puck's top-right corner. Keeping the name out + // from under it with `padding-right` 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". The button moves every hand, so that is a player + // whose name reads properly for four hands and comes apart on the fifth. + const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); + const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/); + const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); + + test("a ring with room for two lines indents only the line beside the chip", () => { + assert.ok(roomy && float, "the roomy regime should use a float"); + assert.match(float[1], /float:\s*right/); + assert.match(roomy[1], /padding-right:\s*0/); + }); + + test("the float is exactly one line tall", () => { + // Tall enough to cover the chip's reach into the name, and no taller: a float + // that spills into the second line indents that one too, which puts the whole + // problem back. + assert.match(float[1], /height:\s*1\.2em/); + }); + + test("break-word stands down where the float is doing the work", () => { + // It is what stops the float working — it breaks a word to fit the short line + // rather than letting it move past the float to the long one. + assert.match(roomy[1], /overflow-wrap:\s*normal/); + }); + + test("a crowded ring keeps the padding, because it has only one line", () => { + // `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has + // nowhere to move to — the only way to keep it off the chip is to shorten it. + // Trying the float on both cost a whole sweep to find out: 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. + assert.ok(base, "the base rule should still reserve the chip's width"); + assert.match(base[1], /padding-right:\s*22px/); + assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/); + }); + + test("the float is as wide as the chip it stands in for", () => { + const chip = CSS.match(/\n\.dealerChip \{[^}]*?(? { // feltRoomFor asks one question — what is under the felt — and gets two different // answers depending on the layout. Under the bar it is the bar. Under the rail it is diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 6d0c633..b80feb5 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -778,18 +778,30 @@ const LayoutAudit = { // concerned it had all the room it needed. Nothing else here would have caught it: // the occlusion check is about controls a thumb has to reach, and this is a label // a dealer has to read. + // Measured against the lines of text as they were painted, not against the box they + // live in. This used to take the box and subtract its padding, on the reasoning that + // "padding is room the text has already been kept out of" — which is true, and is + // not the only way to keep text out of somewhere. A right float does it too, and it + // does it per line, which is the point of using one: the name on the button seat is + // now indented past the chip on the line beside it and full width on the line below. + // Box-minus-padding cannot see that, and called every seat in the sweep an offender + // while the text sat exactly where it was supposed to. + // + // Range rects are the direct question — where did this text actually end up — so + // padding, floats, centring, wrapping and a short label in a wide box all come out + // right without any of them being modelled. Line boxes rather than one bounding box, + // too: a two-line name that clears the chip on the line beside it is not covered + // because its *other* line happens to sit under where the chip is. seatLabelsClear() { const offenders = []; document.querySelectorAll(".seat").forEach(seat => { const name = seat.querySelector(".seatPuckName"); if (!name || !name.textContent.trim()) return; - const box = name.getBoundingClientRect(); - if (!box.width || !box.height) return; - // Padding is room the text has already been kept out of, so it is not - // somewhere the text can be covered. - const style = getComputedStyle(name); - const textLeft = box.left + parseFloat(style.paddingLeft || 0); - const textRight = box.right - parseFloat(style.paddingRight || 0); + + const range = document.createRange(); + range.selectNodeContents(name); + const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1); + if (!lines.length) return; seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => { const over = mark.getBoundingClientRect(); @@ -797,10 +809,15 @@ const LayoutAudit = { const marked = getComputedStyle(mark); if (marked.display === "none" || marked.visibility === "hidden" || parseFloat(marked.opacity) < 0.05) return; - const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left); - const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top); - if (covered > 1 && vertical > 1) { - offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`); + + let worst = 0; + lines.forEach(line => { + const across = Math.min(line.right, over.right) - Math.max(line.left, over.left); + const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top); + if (across > 1 && down > 1) worst = Math.max(worst, across); + }); + if (worst > 1) { + offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`); } }); }); @@ -862,14 +879,28 @@ const LayoutAudit = { // Along the line rather than at its middle: a badge takes the start // of it and a neighbouring puck the end, and a single centre probe // walks past both. + // + // Every probe, not the first one that hits. How much of the line is + // gone is the difference between a stack a dealer cannot read and a + // hairline where a badge's rounded corner grazes the last digit, and + // reporting both as "is under" gave no way to tell them apart — the + // one residual this sweep has carried for three rounds turns out to + // be one probe of seven. + let hits = 0; + let culprit = null; + let probes = 0; for (let across = 0.08; across <= 0.92; across += 0.14) { const x = line.left + (line.right - line.left) * across; const y = (line.top + line.bottom) / 2; + probes++; const over = document.elementFromPoint(x, y); if (!over || seat.contains(over) || over.contains(seat)) continue; - offenders.push( - `seat ${index + 1} "${text}" is under ${this.describe(over)}`); - return; + hits++; + if (!culprit) culprit = over; + } + if (culprit) { + offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} ` + + `under ${this.describe(culprit)}`); } }); }); From 13d704301d69d944e1ff1ce453b6b793443d5bfd Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Fri, 4 Sep 2026 21:33:18 -0600 Subject: [PATCH 03/11] Stand the felt up on a phone, and check which rule actually wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 68 +++++++- tests/holdem/README.md | 29 ++++ tests/holdem/feltShape.test.js | 165 ++++++++++++++++++++ 3 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 tests/holdem/feltShape.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index db0fa37..ffdc411 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3876,9 +3876,57 @@ body.hasActionBar #toastHost { max-height leaves the width alone and lets the aspect ratio give way, so a short screen gets a wide oval, which is the shape a poker table is anyway. Measured at 360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring - that was crowded at 240 comes out clean. Where the room isn't the binding - constraint — 390x844 and up — nothing changes and the table stays round. */ + that was crowded at 240 comes out clean. + + "Where the room isn't the binding constraint — 390x844 and up — nothing changes + and the table stays round" is what this used to say, and it was true and it was + the bug. A square felt on a portrait phone can never be taller than it is wide, + so on every phone with room to spare the surplus was measured, published, and + dropped: 522px of room under a 366px felt on an iPhone 14, 638 under 406 on a Pro + Max. Nothing was wrong with the arithmetic. The shape simply refused the answer. + + So the preferred shape is an oval standing up, and the cap above stays to stop it. + A phone is a portrait screen and this is a table seen from above — the wide layout + has been an oval lying down (0.72) all along, and the two are the same decision + made twice. What it buys is rim: twelve seats at 390x844 went from a 44px puck + with five names clipped to 66px with one, and five seats went from three clipped + to none. + + 1.5 is where the sweep stops paying. 1.6 and 1.7 measured byte-identical to each + other on every phone, because by then the room is the binding constraint again; + between 1.35 and 1.5 the twelve-seat ring on a Pixel 7 goes from one clipped name + to none. The three smaller phones are room-bound at any cap at all and measured + identical from 1.15 to 1.7, so this cannot regress them. + + It was tempting to pick a lower number and claim it bought stillness — a felt + whose height *is* --feltRoomPx moves every time the bar does. That claim was + written here and it was wrong: the bar's growth was measured at 125px resting, + 233px with six players all in, and 341px with twelve and their nine side pots + listed. 216px of growth is more slack than any phone has at any aspect ratio, so + there is no number here that buys stillness. renderSeats() is + already told when the room changes, which is the thing that actually holds the + chips on their seats, and this rule stops trying to. + + A floor under the max-height was tried here and taken out again, which is worth + writing down because it looks obviously right. The worry was that max-height has + no lower bound, so a tall enough bar presses the felt flat; the fix was to floor + it at 72cqw, the wide layout's own oval, so the worst a phone could do was arrive + at the shape the desktop already has. + + It made things worse. Twelve players all in with nine side pots is the tallest the + bar gets — 341px — and at 360x640 that leaves 142px of room. Floored to 242 the + felt keeps its shape and puts five of the twelve seats behind the bar; unfloored + at 142 it is a flat table with all twelve of them visible. Same on an iPhone SE, + three seats against none. A covered seat is the fault this whole measurement + exists to prevent, and a flat table is only ugly. + + The slit the floor was defending 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. + + Landscape overrides all of this outright, further down the file. */ #pokerTableWrap { + --feltAspect: 1.5; max-width: none; max-height: var(--feltRoomPx, 100vh); } @@ -3898,11 +3946,6 @@ body.hasActionBar #toastHost { grid-template-columns: 1fr; } - /* One column, so the table has the full width and can afford to be round. */ - #pokerTableWrap { - --feltAspect: 1; - } - /* No width here any more. This was 78px, and two blocks below it were 64 and 62 — three guesses, at three breakpoints, at the one number that depends on something a media query cannot see: how many people are sitting down. The same 390px phone @@ -3912,6 +3955,17 @@ body.hasActionBar #toastHost { } } +/* One column, and still wide enough that a round table fits. Below 701px the block + further up stands the oval up instead, which is why this has to be written as a + range rather than as another max-width: both queries match a phone, they set the + same custom property at the same specificity, and the one lower in the file wins. + It did win, for a while, and the upright oval above was dead text. */ +@media (min-width: 701px) and (max-width: 900px) { + #pokerTableWrap { + --feltAspect: 1; + } +} + /* ---------- Phones ---------- Two things break on a narrow screen: seat pucks ring the table on a radius measured from the wrap, so the left- and right-most ones hang off the edge of diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 8df8b08..9968a4f 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -23,6 +23,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`. | `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven | | `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot | | `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table | +| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) | | `invariants.test.js` | 1000 randomised hands per property (see below) | ## The layout audit @@ -155,6 +156,34 @@ something changed, and each time they only covered whatever had just been touche That is how the standings table shipped with a player-name column zero pixels wide and kept it for two rounds. +## Which rule wins + +`feltShape.test.js` is here because of a change that measured as correct and was a +no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside +`@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set +the same property back to 1. Both queries match a phone, the declarations have the same +specificity, and the later one wins — so the property was 1 on every phone on the site +while the rule that was supposed to change it sat there reading perfectly. + +Nothing in the suite could have caught it and nothing in review did. A stylesheet is not +a list of rules for viewports; it is a list of rules in an order, and the question "what +does a 390px phone get?" has an answer that no single rule knows. So the test walks the +media queries the way the cascade does — every declaration of a property on a selector, +in source order, keeping the last one whose query matches — and asks what each of five +real phone sizes actually ends up with. It carries just enough of a media-query engine +for the forms this stylesheet uses, and refuses loudly rather than guessing when it +meets one it doesn't know. + +The same file records a second thing, which is a floor that was added and removed. The +`max-height` that keeps the felt above the action bar has no lower bound, so a tall +enough bar presses the table flat; flooring it at the desktop oval looked obviously +right. Measured, it was worse: twelve players all in with nine side pots takes the bar +to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its +shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all +twelve are visible. The slit the floor defended against turned out to be unreachable — +it came from forcing the custom property by hand. A test now asserts the floor is +*absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way. + ## Why the hand evaluator is tested the way it is `handRank.js` is the only module here that can pay the wrong person without anybody diff --git a/tests/holdem/feltShape.test.js b/tests/holdem/feltShape.test.js new file mode 100644 index 0000000..d9c9bf9 --- /dev/null +++ b/tests/holdem/feltShape.test.js @@ -0,0 +1,165 @@ +// What shape the felt ends up, which is a question about which rule wins. +// +// feltRoom.test.js covers how much room there is. This covers what the stylesheet does +// with it, and it exists because of a bug that no amount of reading caught: the upright +// oval for phones was written into the `max-width: 700px` block, and the `max-width: +// 900px` block below it set the same custom property back to 1. Both queries match a +// phone, the two declarations have identical specificity, and the later one wins. The +// rule was correct, the arithmetic was correct, and the property was 1 on every phone +// on the site. It measured as a no-op and would have shipped as one. +// +// So this walks the media queries the way the cascade does — in source order, keeping +// the last one that matches — instead of trusting that a rule written for a viewport is +// the rule that viewport gets. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); + +const CSS = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); + +// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so +// they come out before anything here counts declarations. +const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, ""); + +// --- just enough of a media query engine to answer "does this viewport match?" ------- +// +// Only the forms this stylesheet actually uses: width and height bounds, orientation, +// and `and` between them. Anything else is refused loudly rather than guessed at, so a +// new kind of query can't quietly start matching everything. +const matches = (condition, view) => { + if (!condition) return true; + return condition.split(/\s+and\s+/).every(part => { + const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/); + assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`); + const [, feature, raw] = m; + const px = parseFloat(raw); + switch (feature) { + case "min-width": return view.width >= px; + case "max-width": return view.width <= px; + case "min-height": return view.height >= px; + case "max-height": return view.height <= px; + case "orientation": + return raw === (view.width > view.height ? "landscape" : "portrait"); + default: + assert.fail(`feltShape doesn't know the media feature "${feature}"`); + } + }); +}; + +// Every style rule in the file, in source order, with the media condition it sits +// under. Written as a brace walk rather than a regex because a regex for `max-height` +// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a +// media feature is not a declaration. +const rules = () => { + const out = []; + const open = []; + let prelude = "", i = 0; + while (i < SOURCE.length) { + const c = SOURCE[i]; + if (c === "{") { + const head = prelude.trim(); + prelude = ""; + if (head.startsWith("@")) { open.push(head); i++; continue; } + let depth = 1, j = i + 1; + while (j < SOURCE.length && depth > 0) { + if (SOURCE[j] === "{") depth++; + else if (SOURCE[j] === "}") depth--; + j++; + } + const media = open.filter(h => h.startsWith("@media")) + .map(h => h.replace(/^@media\s*/, "")).join(" and "); + out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) }); + i = j; + continue; + } + if (c === "}") { open.pop(); prelude = ""; i++; continue; } + prelude += c; + i++; + } + return out; +}; + +const ALL_RULES = rules(); + +// The cascade, for equal-specificity declarations of one property on one selector: the +// last one whose query matches is the one the viewport gets. +const winnerAt = (selector, prop, view) => { + const winning = ALL_RULES + .filter(r => r.selector === selector && matches(r.condition, view)) + .map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`))) + .filter(Boolean) + .pop(); + assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`); + return winning[1].trim(); +}; + +const PHONES = [ + { width: 360, height: 640, name: "Galaxy S8" }, + { width: 375, height: 667, name: "iPhone SE" }, + { width: 390, height: 844, name: "iPhone 14" }, + { width: 412, height: 915, name: "Pixel 7" }, + { width: 430, height: 932, name: "Pro Max" }, +]; + +// --------------------------------------------------------------------------- + +describe("the shape a phone gets", () => { + test("is an oval standing up, on every one of them", () => { + PHONES.forEach(phone => { + const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone)); + assert.ok(aspect > 1.2, + `${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` + + `tall and this is what uses it; anything at or under 1 is the square ` + + `felt that threw the room away.`); + }); + }); + + test("and a wide screen still gets one lying down", () => { + // The desktop felt is the shape it always was. Whatever the phones do, this is + // the one a poker table looks like when there is room for it. + assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72); + }); + + test("with the middle widths in between left round", () => { + // 701-900px is one column and wide enough that neither oval is called for. It + // has to be written as a range: a bare max-width would match the phones too and, + // sitting lower in the file, would win there. + [{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => { + assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1); + }); + }); +}); + +describe("what stops the oval", () => { + const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 }); + + test("is the room and nothing but the room", () => { + // The oval is a preference and the room is a fact. Whatever shape is asked for + // above, the felt gives way to the measurement rather than the other way round, + // because the thing on the other side of the cap is a seat behind the action bar. + assert.equal(phoneRule, "var(--feltRoomPx, 100vh)"); + }); + + test("and it is not floored, however tempting that is", () => { + // A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here + // and measured out again. Twelve players all in with nine side pots is the + // tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored + // to 242 the felt keeps its shape and hides five of the twelve seats behind the + // bar. Unfloored at 142 it is flat and all twelve are visible. + // + // The slit it was defending against wasn't reachable: that came from forcing + // --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96. + assert.doesNotMatch(phoneRule, /max\(/, + "a floor here trades a flat table for a covered seat, which is the worse of " + + "the two — see the comment above the rule"); + }); + + test("and the width is let go of, so the height can be the one that binds", () => { + // The wide layout sizes the felt from its width and caps that. On a phone the + // width is the column and the height is what's scarce, so the cap moves. + assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none"); + }); +}); From bd8ee11f777f672378bbc66753cd83e27931d34c Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Sat, 5 Sep 2026 21:23:19 -0600 Subject: [PATCH 04/11] Say who can win each pot in the fewest names that answer it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 9 ++ .../wwwroot/js/casino/holdem/render.js | 45 ++++++- tests/holdem/README.md | 27 ++++ tests/holdem/potBreakdown.test.js | 127 ++++++++++++++++++ tests/holdem/potCalculator.test.js | 60 +++++++++ 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 tests/holdem/potBreakdown.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index ffdc411..b79b26d 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3027,6 +3027,15 @@ body.hasActionBar #holdemSidebar { margin-bottom: var(--sp-4); } +/* The action bar is a fixed strip and its rows have to stay one line each; the wizard + is a dialog that scrolls, and this is the announcement a dealer reads out loud before + anybody shows a hand. So here the names are allowed the room to be read, and nothing + in the announcement is ever cut off. */ +.potBreakdownWizard .potLayerWho { + white-space: normal; + overflow: visible; +} + .potLayerReturn .potLayerLabel, .potLayerReturn .potLayerAmount { color: var(--text-muted); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index b5a8fd7..cf00d9c 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -2096,7 +2096,7 @@ const Render = { wrap.appendChild(title); } - const addRow = (label, amount, who, className) => { + const addRow = (label, amount, who, className, full) => { const row = document.createElement("div"); row.className = `potLayer ${className || ""}`; @@ -2111,6 +2111,11 @@ const Render = { const whoEl = document.createElement("span"); whoEl.className = "potLayerWho"; whoEl.textContent = who; + // The line can still run out of room on a phone. A tooltip is not a mechanism + // a keyboard reaches, which is why the count is in the text itself rather + // than only here — this is the extra for anyone with a pointer, not the + // answer to "how many hands am I asking for". + if (full) whoEl.title = full; row.appendChild(labelEl); row.appendChild(amountEl); @@ -2118,12 +2123,48 @@ const Render = { wrap.appendChild(row); }; - pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", "))); + pots.forEach(pot => addRow( + pot.label, pot.amount, + this.describeEligible(pot.eligible, pots[0].eligible, nameFor), + "", + pot.eligible.map(nameFor).join(", "))); returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn")); return wrap; }, + // Who can win a pot, said in the fewest names that answer it. + // + // Listing every eligible player spelled the same names out again for every pot, and + // the lists that were too long to fit were the ones nobody needed: pot eligibility + // is strictly 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 list contains every other + // list, and the side pot a dealer actually announces by name is the short one at the + // top. Measured on an iPhone 14: a single all-in five-handed already clipped the + // main pot's line, and twelve-handed the first row asked for 521px of a 254px slot. + // + // Nesting is what makes the short form safe, and it is a property rather than a + // habit — potCalculator.test.js checks it across 20,000 random tables — so "who is + // in" and "who is out" are complements and the shorter of the two is never more than + // half the table. + // + // The count leads because .potLayerWho is right-aligned with an ellipsis, so it is + // the tail that gets eaten. A dealer who can only read the start of the line still + // learns how many hands to ask for, which is the number the showdown turns on. + describeEligible(eligible, everyone, nameFor) { + const listOf = names => (names.length <= 1 + ? (names[0] || "") + : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`); + + if (eligible.length >= everyone.length) return `all ${eligible.length}`; + + const inSet = new Set(eligible); + const out = everyone.filter(id => !inSet.has(id)); + return out.length < eligible.length + ? `${eligible.length} in, not ${listOf(out.map(nameFor))}` + : `${eligible.length} in, ${listOf(eligible.map(nameFor))}`; + }, + // ---- Session standings ---- renderStandings() { diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 9968a4f..619123a 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -24,6 +24,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`. | `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot | | `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table | | `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) | +| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) | | `invariants.test.js` | 1000 randomised hands per property (see below) | ## The layout audit @@ -184,6 +185,32 @@ twelve are visible. The slit the floor defended against turned out to be unreach it came from forcing the custom property by hand. A test now asserts the floor is *absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way. +## A property the wording depends on + +`potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it +leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The +main pot is everyone still live, and each side pot above it drops whoever was all-in +below, so every side pot's players are a strict subset of the pot beneath it. + +That makes "who is in" and "who is out" complements, which is what lets the line name +whichever of the two is shorter and never spell out more than half the table. Listing +every eligible player instead meant the main pot's line was every name at the table and +each side pot repeated it minus one — and the lines too long to fit were exactly the ones +nobody needed, while the short ones at the bottom were the side pots a dealer actually +announces by name. + +The nesting check runs 20,000 random tables, and it asserts on its own generator as well +as on the code: if a change to the shapes stopped producing side pots, every assertion in +the loop would be vacuous and the test would still pass, so it also requires that more +than ten thousand of those tables had one. + +Writing that check is where a comparison against `Set.length` went in — a `Set` has +`.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an +assertion it failed everything; as a filter in a throwaway script it had passed +everything, which is how it survived long enough to be written down. Both readings are +silent. Prefer asserting the count you expect over comparing two things whose shapes you +have not checked. + ## Why the hand evaluator is tested the way it is `handRank.js` is the only module here that can pay the wrong person without anybody diff --git a/tests/holdem/potBreakdown.test.js b/tests/holdem/potBreakdown.test.js new file mode 100644 index 0000000..864da0e --- /dev/null +++ b/tests/holdem/potBreakdown.test.js @@ -0,0 +1,127 @@ +// Who can win each pot, said in the fewest names that answer it. +// +// The breakdown used to list every eligible player for every pot. Pot eligibility is +// nested — potCalculator.test.js checks that across 20,000 tables — so the main pot's +// line was every name at the table and each side pot repeated it minus one. The lines +// that were 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 before this: one all-in five-handed already clipped the main +// pot, and twelve-handed the first row wanted 521px of a 254px slot, with no count and +// no title — a dealer reading "Alexandra…" could not tell whether three people or +// eleven were eligible, which is the number the showdown turns on. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render } = require("./load"); + +// Names are "P" so a test reads as ids and the joining is still exercised. +const nameFor = id => `P${id}`; +const say = (eligible, everyone) => Render.describeEligible(eligible, everyone, nameFor); + +describe("the eligible line", () => { + test("says so plainly when the whole table can win it", () => { + // The main pot, every hand. Twelve names to say "nobody is excluded yet" was + // the single longest line in the component and the least informative. + assert.equal(say(["a", "b", "c"], ["a", "b", "c"]), "all 3"); + assert.equal(say(["a"], ["a"]), "all 1"); + }); + + test("names who is out when that is the shorter list", () => { + const table = ["a", "b", "c", "d", "e", "f", "g", "h"]; + assert.equal(say(["b", "c", "d", "e", "f", "g", "h"], table), "7 in, not Pa"); + assert.equal(say(["c", "d", "e", "f", "g", "h"], table), "6 in, not Pa and Pb"); + assert.equal(say(["d", "e", "f", "g", "h"], table), "5 in, not Pa, Pb and Pc"); + }); + + test("names who is in when that is the shorter list", () => { + // The far side pots, which are the ones a dealer announces by name: "side pot + // is 80 between Ben and Carlos". + const table = ["a", "b", "c", "d", "e", "f", "g", "h"]; + assert.equal(say(["g", "h"], table), "2 in, Pg and Ph"); + assert.equal(say(["h"], table), "1 in, Ph"); + assert.equal(say(["f", "g", "h"], table), "3 in, Pf, Pg and Ph"); + }); + + test("and when the two are the same length, says who is in", () => { + // A tie is not a reason to make the dealer do the subtraction: "who can win + // this" is the question, so answer it directly when it costs nothing. + const table = ["a", "b", "c", "d"]; + assert.equal(say(["c", "d"], table), "2 in, Pc and Pd"); + }); + + test("leads with the count, because it is the tail that gets cut off", () => { + // .potLayerWho is right-aligned with an ellipsis, so a line too long for a phone + // loses its end. Every form has to begin with the number of hands to ask for. + const table = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]; + [table, ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], ["k", "l"]] + .forEach(eligible => assert.match(say(eligible, table), /^(all \d+|\d+ in,)/, + `"${say(eligible, table)}" buries the count where a narrow screen can eat it`)); + }); + + test("never spells out more than half the table", () => { + // The point of picking the shorter side. Nested eligibility makes the two lists + // complements, so this is a guarantee rather than a hope. + const table = Array.from({ length: 12 }, (_, i) => `s${i}`); + for (let inCount = 1; inCount <= 12; inCount++) { + const line = say(table.slice(0, inCount), table); + const named = (line.match(/P/g) || []).length; + assert.ok(named <= 6, `${inCount} of 12 named ${named} people: "${line}"`); + } + }); +}); + +// A stub rather than a DOM: the file has no jsdom and does not want one. Enough of an +// element to answer what the row is made of. +const fakeDom = () => { + const make = () => ({ + className: "", textContent: "", title: "", children: [], + classList: { add() {} }, + appendChild(child) { this.children.push(child); return child; }, + }); + global.document = { createElement: make }; + return () => { delete global.document; }; +}; + +describe("the row it builds", () => { + const pots = [ + { label: "Main pot", amount: 300, eligible: ["a", "b", "c", "d"] }, + { label: "Side pot 1", amount: 150, eligible: ["c", "d"] }, + ]; + const textOf = row => row.children.map(c => c.textContent); + + test("carries the full list in a title, for anyone with a pointer", () => { + const undo = fakeDom(); + try { + const wrap = Render.buildPotBreakdown(pots, [], nameFor); + const who = wrap.children.map(row => row.children[2]); + assert.equal(who[0].title, "Pa, Pb, Pc, Pd"); + assert.equal(who[1].title, "Pc, Pd"); + // The visible text is the short form; the title is the long one. A tooltip + // is not a mechanism a keyboard reaches, which is why it is the extra and + // not the answer. + assert.equal(who[0].textContent, "all 4"); + assert.equal(who[1].textContent, "2 in, Pc and Pd"); + } finally { undo(); } + }); + + test("puts the label, the money and the people in that order", () => { + const undo = fakeDom(); + try { + const wrap = Render.buildPotBreakdown(pots, [], nameFor); + assert.deepEqual(textOf(wrap.children[0]), ["Main pot", "$300", "all 4"]); + } finally { undo(); } + }); + + test("and an uncalled bet still reads as money going back", () => { + // Not an eligibility question — nobody is competing for it — so it keeps its + // own wording and gets no title to expand. + const undo = fakeDom(); + try { + const wrap = Render.buildPotBreakdown(pots, [{ id: "a", amount: 25 }], nameFor); + const row = wrap.children[2]; + assert.deepEqual(textOf(row), ["Uncalled", "$25", "back to Pa"]); + assert.equal(row.children[2].title, ""); + } finally { undo(); } + }); +}); diff --git a/tests/holdem/potCalculator.test.js b/tests/holdem/potCalculator.test.js index 7e36d00..0523dfb 100644 --- a/tests/holdem/potCalculator.test.js +++ b/tests/holdem/potCalculator.test.js @@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => { assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/); }); }); + +// The pots come out of layers() in one order and with one relationship between them, +// and the action bar's wording now depends on both. If eligibility were not nested, +// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side +// pot who was never eligible for the main one, and naming the complement would hide +// them. It is a property of how the layers are built rather than an accident of the +// examples above, so it is checked as one. +describe("pot eligibility is nested", () => { + function rng(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; + } + + test("every side pot's players are a strict subset of the pot below it", () => { + const random = rng(20260905); + let multiPotTables = 0; + let pairs = 0; + + for (let table = 0; table < 20000; table++) { + const n = 2 + Math.floor(random() * 11); + const players = Array.from({ length: n }, (_, i) => p( + `p${i}`, + // A sixth of the table never puts anything in, which is a table mid-deal. + random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25, + random() < 0.2)); + + const layers = PotCalculator.layers(players); + if (layers.length > 1) multiPotTables++; + + for (let i = 1; i < layers.length; i++) { + const below = layers[i - 1].eligible; + const belowSet = new Set(below); + const here = layers[i].eligible; + pairs++; + assert.ok(here.every(id => belowSet.has(id)), + `table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` + + JSON.stringify(players)); + assert.ok(here.length < below.length, + `table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` + + `layers with the same eligibility are supposed to be merged into one pot\n` + + JSON.stringify(players)); + } + } + + // Guards the generator rather than the code: a shape that stopped producing side + // pots would make every assertion above vacuous and the test would still pass. + assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`); + assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`); + }); + + test("so the main pot is everyone still live, and it is the widest", () => { + const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]); + assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]); + layers.slice(1).forEach(layer => + assert.ok(layer.eligible.length < layers[0].eligible.length)); + }); +}); From 6eb97c8a35322866cec211543254f4105058acdc Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Sat, 5 Sep 2026 21:52:34 -0600 Subject: [PATCH 05/11] Give the sidebar eight names instead of five and a cupboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 97 +++++++++----- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 20 +++ tests/holdem/README.md | 34 +++++ tests/holdem/sidebarPanels.test.js | 124 ++++++++++++++++++ 4 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 tests/holdem/sidebarPanels.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 731b53f..fb4b8ca 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -11,8 +11,9 @@ phone the felt did not start until 225px down — a quarter of the screen spent before the dealer saw the table. Only two of the six are per-hand controls. The rest have gone where they belong rather than into a "more" menu, which - would have relocated the crowding rather than removed it: Reset Table to the - Session panel beside Clear history and the save/load pair, Quick Split down + would have relocated the crowding rather than removed it: Reset Table down to + the sidebar with the save/load pair — in a panel called Save & reset, so the + danger is on the label rather than at the end of a scroll — Quick Split down with Common rulings where the other "open something else" link already lives, and the way back to the site up top where a back link goes. *@
@@ -67,7 +68,7 @@ loaded: open on a table nobody has set up yet, shut on one already half-way through a night. *@
- Table Setup +

Table Setup

+ @* These four were one panel called "Session", which is 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, which + is four kinds of thing under one label. It measured at 1206px and 103 + elements, and "The table itself" was 1052px down inside it, so reaching + Reset table meant opening a panel called Session and scrolling past the + money and the log to get there. + + Each panel is named for the thing a dealer came looking for, and the + subheads that used to do that job are gone, because the summary says it + now. They are 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 from the rest, + the two buttons that end it. *@
- Session +

Standings

-

Standings

@@ -165,14 +178,6 @@

Everyone who has bought in shows up here, with what they'd be owed if the night ended now.

-

Waiting list

-
- - -
-
    -

    Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.

    - +
    -

    Hands

    +
    +

    Waiting list

    +
    +
    + + +
    +
      +

      Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.

      +
      +
      + +
      +

      Hands

      +
      @@ -218,27 +239,11 @@
      - -

      The table itself

      -

      - The night lives in this browser and nowhere else. Save it to a file to carry it - to another device, or to have something to come back to if this one dies. -

      -
      - - - - @* Down from the header, where it sat in the same weight as Resolve Pot - — the most-pressed control in a session next to the one that throws - the night away. Here it is beside Clear history, which is the other - thing that destroys records, and behind a panel you have to open. *@ - -
      - Chip Legend +

      Chip Legend

      @@ -253,7 +258,7 @@
      - Keyboard +

      Keyboard

      Nothing fires while you're typing in a field, or while a sheet is open over the table.

      @@ -261,11 +266,37 @@
      - Table Notes +

      Table Notes

      + + @* Last, and on its own, because everything in it is about the night as a whole + rather than the hand in front of you — and because two of the three buttons + are the ones you cannot take back. + + Reset table came down from the header a while ago, where it sat in the same + weight as Resolve Pot: the most-pressed control in a session beside the one + that throws the night away. It used to be buried at the bottom of Session, + 1052px down, which hid it but also meant nobody could find it on purpose. + Naming the panel for both jobs puts the danger on the label, where it is + read before the panel is opened rather than met at the end of a scroll. *@ +
      +

      Save & reset

      +
      +

      + The night lives in this browser and nowhere else. Save it to a file to carry it + to another device, or to have something to come back to if this one dies. +

      +
      + + + + +
      +
      +
      diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index b79b26d..8153fd9 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2217,6 +2217,26 @@ button, letter-spacing: 0.03em; } +/* The panel's name is a heading as well as a control. + + A is announced as a button and not as a heading, so the sidebar's names — + which are the whole of its navigation — were invisible to anyone moving through the + page by heading. The four that were headings were the subheads buried inside the old + Session panel, and splitting it took those away, so without this the page would have + lost the outline rather than gained one. The spec allows heading content in a summary + and both VoiceOver and NVDA expose it. + + It has to look like nothing happened, so the heading gives up everything a heading + brings with it. Inline, because a block child of a summary pushes the disclosure + triangle onto its own line. */ +.sidebarPanel > summary > h2 { + display: inline; + margin: 0; + font: inherit; + color: inherit; + letter-spacing: inherit; +} + .panelBody { padding-bottom: var(--sp-4); display: flex; diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 619123a..7762d9b 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -25,6 +25,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`. | `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table | | `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) | | `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) | +| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) | | `invariants.test.js` | 1000 randomised hands per property (see below) | ## The layout audit @@ -211,6 +212,39 @@ everything, which is how it survived long enough to be written down. Both readin silent. Prefer asserting the count you expect over comparing two things whose shapes you have not checked. +## Testing a fault that broke nothing + +`sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because +the fault it covers was not a bug. Every control worked and every id resolved. What was +wrong was which panel a thing was in and what that panel was called. + +The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and +Session held the standings, a waiting list, the hand log and the save/load/reset buttons +— a topic, a task, a log and three utilities under one word. It measured 1206px and 103 +visible elements against 555 and 29 for the next biggest, and "The table itself" sat +1052px down inside it, so reaching Reset table meant opening a panel called Session and +scrolling past the money and the log to find it. + +Three things are asserted, and each of them is a claim a future edit could quietly undo: + +- **The names say what is inside them.** The list is checked literally, because the + failure mode is a word like "Session" that could hold anything. +- **Each name is a heading.** A `` is announced as a button, so the sidebar's + names — its whole navigation — were not reachable by heading. Splitting Session also + removed the four subheads that had been the only headings down there, so without a + heading in each summary the page would have come out of this with a worse outline than + it went in with. +- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is + legitimately long. It counts *subjects*: a panel that needs its own headings to list + its parts is holding more than one thing. Two subheads survive, and the test names + them, because Settle up is the standings at the end of the night and Stacks in chips + is the chip legend counted out — the same subject at a different moment, which is what + a subhead is for. + +The panels are split on the opening tag rather than parsed, which is sound only because a +sidebar panel never contains another one; Table Setup's sub-panels carry a different +class. That assumption is worth knowing about if one ever does. + ## Why the hand evaluator is tested the way it is `handRank.js` is the only module here that can pay the wrong person without anybody diff --git a/tests/holdem/sidebarPanels.test.js b/tests/holdem/sidebarPanels.test.js new file mode 100644 index 0000000..89fdc48 --- /dev/null +++ b/tests/holdem/sidebarPanels.test.js @@ -0,0 +1,124 @@ +// What the sidebar is a list of. +// +// The panels were Table Setup, Session, Chip Legend, Keyboard and Table Notes, and +// "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: a +// navigation layer with more than one logic in it. Measured at 1206px and 103 visible +// elements, against 555 and 29 for the next biggest, and "The table itself" sat 1052px +// down inside it, so reaching Reset table meant opening a panel called Session and +// scrolling past the money and the log. +// +// This is a markup test because that is where the fault lived. Nothing was broken — +// every control worked and every id resolved — so nothing failed. What was wrong was +// which panel a thing was in and what that panel was called. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); + +const PAGE = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); + +// A sidebar panel never contains another one — Table Setup's sub-panels carry a +// different class — so splitting on the opening tag is enough to say which panel any +// piece of markup is in, without parsing the document. +const panels = () => PAGE + .split(/
      { + const name = chunk.match(/\s*(?:

      )?([^<]+)/); + return { name: name ? name[1].trim() : "(no summary)", markup: chunk }; + }); + +const panelHolding = id => { + const found = panels().filter(p => p.markup.includes(`id="${id}"`)); + assert.equal(found.length, 1, `${id} is in ${found.length} panels, expected exactly 1`); + return found[0].name; +}; + +// --------------------------------------------------------------------------- + +describe("the sidebar's names", () => { + test("say what is inside them", () => { + // Every one of these is a thing a dealer goes looking for. None of them is a + // word that could hold anything — which is the test "Session" fails. + assert.deepEqual(panels().map(p => p.name), [ + "Table Setup", + "Standings", + "Waiting list", + "Hands", + "Chip Legend", + "Keyboard", + "Table Notes", + "Save & reset", + ]); + }); + + test("and each one is a heading, not only a button", () => { + // A is announced as a button. The sidebar's names are the whole of its + // navigation, so without a heading inside, none of them is reachable by anyone + // moving through the page by heading — and splitting Session removed the four + // subheads that used to be the only headings down here. + panels().forEach(p => assert.match(p.markup, /

      /, + `the ${p.name} panel's name is not a heading`)); + }); +}); + +describe("where things live", () => { + test("the money is together, and the warning about losing it is with it", () => { + assert.equal(panelHolding("standingsList"), "Standings"); + assert.equal(panelHolding("settleUp"), "Standings"); + assert.equal(panelHolding("bankReconcile"), "Standings"); + // renderStorageWarning() says this "sits at the top of the panel a dealer opens + // to check the money". That panel is now called Standings. + assert.equal(panelHolding("storageWarning"), "Standings"); + }); + + test("the queue for a seat is its own panel", () => { + assert.equal(panelHolding("waitingList"), "Waiting list"); + assert.equal(panelHolding("waitingListForm"), "Waiting list"); + }); + + test("the log and the two ways out of it are together", () => { + assert.equal(panelHolding("handHistoryList"), "Hands"); + assert.equal(panelHolding("handHistoryFilter"), "Hands"); + assert.equal(panelHolding("exportHistoryBtn"), "Hands"); + assert.equal(panelHolding("clearHistoryBtn"), "Hands"); + }); + + test("and the file is last, with the danger on the label", () => { + assert.equal(panelHolding("saveTableBtn"), "Save & reset"); + assert.equal(panelHolding("loadTableBtn"), "Save & reset"); + assert.equal(panelHolding("resetTableBtn"), "Save & reset"); + assert.equal(panels()[panels().length - 1].name, "Save & reset", + "the panel that ends the night belongs at the end, away from the working ones"); + }); +}); + +describe("no panel goes back to being a grab-bag", () => { + // Not a size limit — Table Setup is legitimately long and Chip Legend legitimately + // repeats a row per chip. What this counts is *subjects*: a panel that needs its own + // headings to say what its parts are is a panel holding more than one thing, which + // is what Session was and how it got that way. + test("at most one subject needs a heading inside a panel", () => { + panels().forEach(p => { + const subheads = [...p.markup.matchAll(/

      ([^<]+) m[1].trim()); + assert.ok(subheads.length <= 1, + `${p.name} has ${subheads.length} sections inside it (${subheads.join(", ")}) — ` + + `if a panel needs headings to list its parts, its name is not doing the work`); + }); + }); + + test("and the ones that remain are a detail of their panel, not a second subject", () => { + // Settle up is the standings once the night ends; Stacks in chips is the chip + // legend counted out. Both are the same subject at a different moment, which is + // the case a subhead is for. + const kept = panels().flatMap(p => + [...p.markup.matchAll(/

      ([^<]+) `${p.name}: ${m[1].trim()}`)); + assert.deepEqual(kept, ["Standings: Settle up", "Chip Legend: Stacks in chips"]); + }); +}); From cd2db6346e766e3d0d240fd0ebabc94b61cb2f1e Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Sat, 5 Sep 2026 22:26:16 -0600 Subject: [PATCH 06/11] Stop a chip colour taking the table's money with it, quietly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .../wwwroot/js/casino/holdem/main.js | 48 +++++ .../wwwroot/js/casino/holdem/render.js | 23 ++- .../wwwroot/js/casino/holdem/state.js | 78 ++++++++ tests/holdem/README.md | 47 +++++ tests/holdem/chipLegend.test.js | 183 ++++++++++++++++++ tests/holdem/uiAudit.js | 65 +++++++ 6 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 tests/holdem/chipLegend.test.js diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index cc0a4d6..2e09cfb 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -86,6 +86,54 @@ document.addEventListener("DOMContentLoaded", () => { () => HoldemState.removeFromWaitingList(state, id)); }, onGiveButton: index => seatMenuHooks.makeDealer(index), + + // Removing a chip colour, which is only sometimes a small thing. + // + // A colour nobody has counted is a row in a legend, and taking it out should + // cost a click and nothing else. A colour seats are holding is money: the + // legend is what prices their chips, so the row leaving takes their chips off + // the table with it — and it cannot be put back, because a colour added again + // is a new one and the counts belong to the old. + // + // So the friction is on the second case only. A confirmation that fires every + // time is one nobody reads by the third night, and the one that matters here + // would go the same way as the four before it that didn't. + removeChipColor: async id => { + const usage = HoldemState.chipColorUsage(state, id); + if (!usage.chip) return; + const name = Number(usage.chip.value) > 0 + ? `${Money.format(usage.chip.value)} chip` + : "chip colour"; + + if (usage.total !== 0) { + const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`; + const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", "); + const ok = await Dialog.confirm({ + // Both directions are named rather than only the loss. Going up is + // the odd one — seats falling back to the table's standing ante once + // their chips are gone — and a dealer who is told the total will move + // and then finds it moved the other way has been told something false. + title: usage.total > 0 + ? `Remove the ${name} and ${Money.format(usage.total)} with it?` + : `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`, + message: + `${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` + + `${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` + + `when the colour goes. Undo will put it back; adding the colour again will not.`, + confirmLabel: "Remove it", + danger: usage.total > 0, + }); + if (!ok) return; + } + + runUndoable( + usage.total > 0 + ? `${name} removed — ${Money.format(usage.total)} off the table.` + : usage.total < 0 + ? `${name} removed — table up ${Money.format(-usage.total)}.` + : `${name} removed.`, + () => HoldemState.removeChipColor(state, id)); + }, }; const seatMenuHooks = { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index cf00d9c..488f02a 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -2006,25 +2006,38 @@ const Render = { const row = template.content.firstElementChild.cloneNode(true); const colorInput = row.querySelector(".chipColorInput"); const valueInput = row.querySelector(".chipValueInput"); + const removeBtn = row.querySelector(".removeChipColorBtn"); colorInput.value = chip.color; valueInput.value = chip.value; + // "×" is the whole of this button, so without a name it is announced as a + // button and nothing else — and there are five of them in a column, all + // identical, each doing something different and none of them small. The + // value is what tells them apart, and it changes as it is typed, so the + // name is kept up with it rather than set once. + const nameRemove = () => { + const worth = Number(valueInput.value) || 0; + removeBtn.setAttribute("aria-label", worth > 0 + ? `Remove the ${Money.format(worth)} chip` + : "Remove this chip colour"); + }; + nameRemove(); + colorInput.addEventListener("input", () => { chip.color = colorInput.value; this.hooks.save(this.state); }); valueInput.addEventListener("input", () => { chip.value = Number(valueInput.value) || 0; + nameRemove(); this.hooks.save(this.state); this.renderSeats(); this.renderSeatEditor(); this.updatePotBadge(); }); - row.querySelector(".removeChipColorBtn").addEventListener("click", () => { - this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id); - this.hooks.save(this.state); - this.renderAll(); - }); + // Through the hook rather than done here, because what this costs depends on + // what the seats are holding — see removeChipColor in main.js. + removeBtn.addEventListener("click", () => this.hooks.removeChipColor(chip.id)); list.appendChild(row); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index bfbf74d..c658db9 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -1139,6 +1139,84 @@ const HoldemState = { return Math.max(0, Money.round(Betting.stackOf(state, player))); }, + // What a chip colour is holding up. + // + // Removing one is not a change to a legend. computeContribution() prices a seat by + // running the legend over that seat's chip counts, so a colour that leaves the + // legend takes every chip counted in it off the table. Measured on a three-handed + // table counting real chips: deleting the $500 row took $22,500 of $26,874 with it, + // on one click of a 24px button, with nothing said and nothing shown. + // + // It does not come back, either. A colour added again 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. + // + // Hence this: the cost is answerable before the click rather than discovered at the + // end of the night, when the box is short and nobody knows why. + // The number here is the *difference*, asked by pricing every seat twice — once with + // the colour and once without — rather than by multiplying the chips by their value. + // + // Those two are not the same, and the multiplication is the wrong one, because + // computeContribution() does not always reach the chips. A seat with a typed amount + // is priced from that and its chip counts are ignored, so a colour it holds is worth + // nothing to it; and a seat whose chips all leave falls through to the table's base + // pot — the standing ante — rather than to zero, so it loses less than its chips + // were worth. + // Both would put a number in the confirmation that the table then failed to match, + // which is worse than no number at all — this is a warning whose whole job is to be + // the truth. + chipColorUsage(state, chipId) { + const chip = (state.chipLegend || []).find(entry => entry.id === chipId); + if (!chip) return { chip: null, total: 0, chips: 0, seats: [] }; + + const legend = state.chipLegend || []; + const without = legend.filter(entry => entry.id !== chipId); + + const seats = state.seats + .map((player, index) => { + const before = this.computeContribution(player, legend, state.basePotAmount); + const after = this.computeContribution(player, without, state.basePotAmount); + return { + index, + name: this.displayName(player, index), + count: Number((player.chipCounts || {})[chipId]) || 0, + amount: Money.round(before - after), + }; + }) + // Signed, and not filtered to losses, because removing a colour can put the + // table *up*. A seat whose chips are worth less than the table's standing + // ante falls through to the ante once they are gone: found by the randomised + // check at 14 chips of a $1 colour against a $200 base pot, where deleting it + // moved the table by +186. Rare, but a warning that said "nothing changes" + // there would be as wrong as one that named the wrong number. + .filter(row => row.amount !== 0); + + return { + chip, + seats, + chips: seats.reduce((sum, row) => sum + row.count, 0), + total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)), + }; + }, + + // Take a colour out, and take its counts with it. + // + // Leaving them behind is what the old code did — the seats kept counts against an + // id no legend entry answers to any more, so the file carried money that nothing + // could price and nothing could show. Clearing them makes the saved table say what + // the screen says. The usage is measured before anything moves, so a caller can + // report what it cost. + removeChipColor(state, chipId) { + const usage = this.chipColorUsage(state, chipId); + if (!usage.chip) return usage; + + state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId); + state.seats.forEach(player => { + if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId]; + }); + return usage; + }, + // Seats playing with chips the box has no cash against. // // Two kinds, and they are not the same problem. A seat holding the table's starting diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 7762d9b..d0e48d5 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -26,6 +26,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`. | `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) | | `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) | | `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) | +| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) | | `invariants.test.js` | 1000 randomised hands per property (see below) | ## The layout audit @@ -245,6 +246,52 @@ The panels are split on the opening tag rather than parsed, which is sound only sidebar panel never contains another one; Table Setup's sub-panels carry a different class. That assumption is worth knowing about if one ever does. +## A warning is only worth having if its number is right + +`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled +24px button and was never a change to a legend. `computeContribution()` prices a seat by +running the legend over that seat's chip counts, so a colour that leaves takes every chip +counted in it off the table. Measured in a browser on a three-handed table: deleting the +$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said +afterwards, and no way back — a colour added again is a new colour with a new id, while +the counts stay keyed to the one that left. + +Most of the file is about the number in the warning rather than the removal, because a +warning that overstates is worse than none. The obvious way to work it out — chips times +value — is wrong in **both** directions: + +- a seat with a typed amount is priced from that and its counts are never read, so a + colour it holds is worth nothing to it; +- a seat whose chips all leave falls through to the table's standing ante rather than to + zero, so it loses less than its chips were worth. + +So the figure is the difference between pricing every seat with the colour and without +it, and a randomised check across 2,000 tables asserts that what the warning claims is +exactly what the table then does. That check found a third case nobody would have written +by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table +**up** $186. The wording says so in that direction too. + +The friction is conditional on purpose. A colour nobody has counted deletes on one click; +only one that is holding money asks. A confirmation that fires every time is one nobody +reads by the third night, and this is a tool where four other confirmations already +compete for that attention. + +## Every control says what it is + +`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the × +had no accessible name, so it was announced as a button and nothing else — five of them +in a column, identical, each deleting a different colour. + +It treats a symbol as no name even though `×` is technically text. The rule is that a +name has to contain a letter or a digit, which is the difference between "×" and "Undo". +Names are resolved the way the accessibility tree does it — `aria-label`, then +`aria-labelledby`, then a field's wrapping or pointing `