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..dbd7cbf 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. +

      +
      + + + + +
      +
      +
      @@ -641,6 +672,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..9b53a95 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -264,6 +264,42 @@ 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; +} + +: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; @@ -439,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; } @@ -933,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; @@ -946,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; @@ -980,6 +1075,33 @@ button, line-height: 1.4; } +/* Neither row has anything in it most of the time, and both were holding their space + anyway — the cards line on every seat of every table that isn't tracking hole cards, + and the amount pill on everyone who hasn't put chips in yet. + + The pill was worse than empty space. It has a background, a pill radius and side + padding, so with no text it painted an 8x11px green lozenge in the middle of the puck + — measured on seven of nine seats at an ordinary table, a coloured mark on almost + every seat that stood for nothing. Nine of those is not a table, it is a rash. + + The space they held was not free either. The ring is sized against the tallest puck + on it, so two rows nobody had filled were setting how far apart twelve seats had to + sit: on an iPhone 14 nine-handed the puck measured 78x88 with 25px of it reserved for + nothing. Collapsed, the search settles on 78x63 — same width for the name, a quarter + less height to fit on the ring. + + `:empty` is exact here: both rows are written by updatePuckContent, which sets an + empty string rather than a space when there is nothing to say. What the collapse + needs from the layout is that the ring re-measures when one of them fills — see + seatShapeSignature() in render.js, which now names both. */ +.seatPuckCards:empty { + display: none; +} + +.seatPuckTotal:empty { + display: none; +} + .dealerChip { position: absolute; /* Inside its own seat, not on the rim. @@ -1092,13 +1214,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 +1255,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 @@ -1217,6 +1358,33 @@ button, font-size: var(--fs-2xs); } +/* The other end of the same idea. A puck past the ordinary 96px ceiling has room the + type was never given: the felt grows with the window and the text did not, so a + dealer on a 2560px monitor read their stacks at the same 10px as one on a 360px + phone, on a table where the seats had dropped to 8% of the felt. + + Two steps up the scale for the two numbers a dealer reads across the table, one for + the chair number in the corner, which is a reference rather than a reading. Not + proportional to the width — 96 to 144 is 1.5x and this is 1.25x — because a name is + a label on a puck, not a headline, and the puck also has to hold it: 15px is the + largest size at which "Mike from work", the longest name the sweep uses, still comes + out on one line at 144px. + + Steps on the scale rather than a clamp(), for the same reason the tight set is: the + width is a continuum and the type is a set of deliberate sizes. */ +.seatsRoomy .seatPuckName { + font-size: var(--fs-md); +} + +.seatsRoomy .seatPuckStack, +.seatsRoomy .seatPuckTotal { + font-size: var(--fs-sm); +} + +.seatsRoomy .seatChairNumber { + font-size: var(--fs-xs); +} + /* The hole cards give up their line here. A puck this close to its neighbours is 64px tall and the seats at the ends of a landscape felt are 55px apart, so the line has to come from somewhere — and of the four things on a puck, the cards are the one the @@ -2103,6 +2271,77 @@ 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; +} + +/* The one fact a shut panel is worth opening for, said on the row itself. + See Render.panelGlances for what each one says and why. + + Taken out of flow rather than laid out beside the label, because a only + draws its disclosure triangle while it is `display: list-item` — the same constraint + that put padding rather than a flex box on the row above. + + Out of flow is also the guarantee that matters here: nothing about the glance may + make a sidebar row taller, and an absolutely positioned box cannot. Floating it did + keep the row at 60px, but it was aligned to the label's line box rather than to the + row, and a 12px string in a box sized for 15px text rode 2.5px low on all six panels. + Centring against the row is both the right alignment and the smaller promise. + + It lands 1.5px below the label, measured, and that is the label's offset rather than + this one's: the row's 44px is a min-height, so the three pixels it adds to a 41px + header all go on the bottom and leave the label sitting high in its own row. The + glance is on the row's centre, which is the stable thing to be on. */ +.sidebarPanel > summary { + position: relative; +} + +.panelGlance { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + /* The labels are short and fixed — "Waiting list" is the longest at under a third + of the column — so this is room to spare, not a division of the row. */ + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-sm); + letter-spacing: 0; + color: var(--text-muted); + /* The amounts change while a hand is being dealt, and a proportional font shifts + the whole string sideways every time a digit does. */ + font-variant-numeric: tabular-nums; +} + +/* The same two colours the bank check's own sentence uses, because this is that + sentence with the explanation taken off — a glance that agreed with the panel in + words and disagreed with it in colour would be worse than no glance. */ +.panelGlanceOk { + color: #7fc99a; +} + +.panelGlanceWarn { + color: #f0b46a; +} + .panelBody { padding-bottom: var(--sp-4); display: flex; @@ -2532,6 +2771,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 @@ -2554,9 +2797,52 @@ button, display: none; } -/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */ -body.hasActionBar #holdemSidebar { - padding-bottom: 9rem; +/* The bar is fixed over the page, so the page needs room to scroll past it. + + This was 9rem on the sidebar alone, which gave the column of panels somewhere to go + and left the table's column with nothing. That is most of the page on a desktop, and + it is where the two links under the felt live: measured at 1280x900, the page had 36px + of scroll in it against the 94px needed to lift Common rulings out from under a toast, + so the focus handler asked for a scroll the page could not make. + + On the whole app rather than one column, and taken from the bar's published height + rather than guessed, so it is right in both layouts — 0 when the bar is a rail down + the side, which takes no height at all. The fallback keeps the old number for the + moment before the first measurement lands. */ +body.hasActionBar #holdemApp { + padding-bottom: calc(var(--actionBarHeight, 9rem) + var(--sp-4)); +} + +/* ---------- 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 { @@ -2877,12 +3163,32 @@ 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); font-weight: 500; } +/* The layers between the main pot and the newest side pot, collapsed onto one line so + the bar's height stops depending on how many people are all-in. Set back rather than + picked out: it is the one row in the list that is not a pot to announce, and reading + as a pot is exactly the mistake it could cause. */ +.potLayerMore .potLayerLabel, +.potLayerMore .potLayerAmount { + color: var(--text-muted); + font-weight: 500; + font-style: italic; +} + .actionButtons { display: flex; gap: var(--sp-2); @@ -3726,9 +4032,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); } @@ -3748,11 +4102,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 @@ -3762,6 +4111,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 @@ -3853,12 +4213,25 @@ body.hasActionBar #toastHost { background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65); transform: translateY(105%); - transition: transform 0.25s ease; + /* A transform moves a thing; it does not put it away. Parked off the bottom of + the window the sheet was still in the tab order and still in the accessibility + tree, so tabbing through a phone reached its Done button at y=850 on an 844px + screen — focused, announced, and impossible to see or scroll to, because a + fixed element does not move when the page does. + + visibility does put it away: hidden takes the subtree out of the focus order + and off the tree, and unlike display it can be transitioned. The delay is what + keeps the slide-out visible — visibility flips at the end of the 0.25s on the + way out, and immediately on the way in. */ + visibility: hidden; + transition: transform 0.25s ease, visibility 0s linear 0.25s; -webkit-overflow-scrolling: touch; } body.seatSheetOpen #seatEditorPanel { transform: translateY(0); + visibility: visible; + transition: transform 0.25s ease, visibility 0s; } .seatSheetGrip { @@ -4264,6 +4637,250 @@ 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); + } +} + +/* ---------- Focus is brought to rest where it can be seen ---------- + + The ring below was the easy half. The hard half is that the bar is fixed over the + bottom of the page, so it is not in the flow and the browser does not know it is + there: Tab to a control near the bottom and the browser scrolls it flush against the + edge of the window — under the bar — and considers the job done. Measured by tabbing + the whole page on a 390x844 phone: 16 stops came to rest completely covered and 27 + more partly. That is WCAG 2.2 SC 2.4.11, which asks that a focused control not be + entirely hidden by something the page has put on top of it. + + scroll-padding is the fix, and it goes on the scroll container rather than on the + controls: it tells the browser to treat the bottom of the viewport as not there when + it scrolls something into view. The number comes from the bar, which already + publishes its own height for the toast and the felt, so it cannot drift from the + bar's real size the way a guess would — and it is already 0 when the bar is hidden or + has become a rail down the side, which is the case scroll-padding-right answers. + + The toast publishes its height into the same sum, because when the browser does + scroll it has to clear both. + + This is only half of it, though, and the half that is easy to mistake for all of it. + scroll-padding only applies when the browser scrolls at all, and a control already + inside the viewport is one it sees no reason to move — a fixed overlay on top of it + does not enter into the decision. Measured on a 1280x900 desktop: tabbing to Common + rulings while a toast was up left it completely covered, and this rule did nothing + about it, because nothing scrolled. The other half is the focusin handler in main.js, + which measures the overlap and scrolls by it. */ +html { + scroll-padding-bottom: calc( + var(--actionBarHeight, 0px) + var(--toastHeight, 0px) + + var(--sp-4) + env(safe-area-inset-bottom, 0px)); + scroll-padding-right: var(--actionRailW, 0px); +} + /* ---------- 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 +4943,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..9d9a7b8 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 @@ -571,10 +606,15 @@ const ActionBar = { this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)]) ); + // Four is what the bar can hold without taking the felt with it — see + // buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check + // for the ceiling this is keeping the bar under. return Render.buildPotBreakdown( PotCalculator.layers(contributions), PotCalculator.uncalledReturns(contributions), - id => nameById.get(id) + id => nameById.get(id), + null, + { maxLayers: 4 } ); }, diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js index b5813f1..423bc8d 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/feedback.js @@ -46,7 +46,10 @@ const Toast = { host.appendChild(toast); // Next frame, so the entrance transition actually runs. - requestAnimationFrame(() => toast.classList.add("toastVisible")); + requestAnimationFrame(() => { + toast.classList.add("toastVisible"); + this.publishHeight(); + }); this.timer = setTimeout( () => this.dismiss(), @@ -61,6 +64,24 @@ const Toast = { } const host = document.getElementById("toastHost"); if (host) host.innerHTML = ""; + this.publishHeight(); + }, + + // How much of the bottom of the window this is standing on. + // + // The toast is fixed, so it is not in the flow and nothing scrolling a control into + // view knows it is there — measured on the desktop sweep, tabbing to Common rulings + // while a toast was up left the focus ring entirely behind it. The stylesheet adds + // this to the bar's own height to work out where a focused control has to stop, so + // it is published rather than guessed: the toast is one line or two depending on the + // message, and on a narrow screen the same message is taller again. + publishHeight() { + if (typeof document === "undefined" || !document.documentElement) return; + const host = document.getElementById("toastHost"); + const height = host && host.firstElementChild + ? Math.round(host.getBoundingClientRect().height) + : 0; + document.documentElement.style.setProperty("--toastHeight", `${height}px`); }, }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index cc0a4d6..9623219 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 = { @@ -467,6 +515,12 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("tableNotes").addEventListener("input", e => { state.notes = e.target.value; hooks.save(state); + // The one field on the page that must not go through renderAll: it would write + // the textarea's own value back into it on every keystroke and put the caret at + // the end. The panel's shut-state summary still has to keep up, though, and it + // is the only thing on the page that reads the notes — so it gets called on its + // own. It touches the six s in the sidebar headers and nothing else. + Render.renderPanelGlances(); }); document.getElementById("addChipColorBtn").addEventListener("click", () => { @@ -616,6 +670,70 @@ document.addEventListener("DOMContentLoaded", () => { }, 0); }); + // Focus that lands underneath something fixed is moved out from under it. + // + // scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It + // works when the browser has to scroll to reach the control at all — then the + // padding tells it where to stop. A control the browser already considers visible is + // one it will not scroll to at all, and a fixed overlay sitting on top of it does + // not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a + // toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved + // nothing, because "nearest" was already satisfied. + // + // So the overlap is worked out here and the page is scrolled by exactly it. Named + // overlays rather than every fixed element on the page, because this runs on every + // focus change and walking the whole DOM to find three known things would be work + // done ten times a second to reach the same answer. Anything inside one of them is + // left alone — the bar's own buttons are meant to be on top of the page. + const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel"; + document.addEventListener("focusin", event => { + const el = event.target; + if (!el || typeof el.getBoundingClientRect !== "function") return; + if (el.closest && el.closest(FIXED_OVERLAYS)) return; + + const box = el.getBoundingClientRect(); + if (box.width === 0 || box.height === 0) return; + + let lift = 0; + document.querySelectorAll(FIXED_OVERLAYS).forEach(node => { + const style = getComputedStyle(node); + if (node.hidden || style.visibility === "hidden" || style.display === "none") return; + if (style.position !== "fixed") return; + const over = node.getBoundingClientRect(); + if (over.width === 0 || over.height === 0) return; + // Only what is actually in the way: an overlay off to one side, like the + // rail on a phone held sideways, is not covering a control in the middle. + if (box.left >= over.right || box.right <= over.left) return; + if (box.bottom <= over.top || box.top >= over.bottom) return; + lift = Math.max(lift, box.bottom - over.top + 8); + }); + + // Instant rather than smooth. This is the page getting out of the way of + // something the dealer has already arrived at, and animating it would put the + // control they are typing into in motion under them. + if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" }); + + // Scrolling is not always enough. The page only has as much room below its + // content as the padding under the app gives it, and that is sized for the bar + // — the toast stands on top of the bar, so a control at the very end of the page + // can be lifted clear of one and not the other. Measured at 1280x900: 94px of + // lift wanted, 26px of page to do it in. + // + // Sizing the padding for the toast as well would fix it by making the page grow + // and shrink by 64px every time anything is announced, which is a worse thing to + // do to a dealer than the fault. So the toast gives way instead: it is an + // eight-second message about something that has already happened, and it is + // standing on a control somebody has just navigated to. Only after the scroll + // has been tried, so a toast that is not in the way is left alone. + const toast = document.querySelector("#toastHost .toast"); + if (!toast) return; + const after = el.getBoundingClientRect(); + const over = toast.getBoundingClientRect(); + const stillCovered = after.left < over.right && after.right > over.left && + after.bottom > over.top && after.top < over.bottom; + if (stillCovered) Toast.dismiss(); + }); + document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard); [ diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index b45a272..0be37a8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -34,6 +34,7 @@ const Render = { this.renderNotes(); this.updatePotBadge(); this.renderSetupFields(); + this.renderPanelGlances(); this.renderHeaderActions(); this.renderTableHint(); this.renderStandings(); @@ -65,6 +66,105 @@ const Render = { if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`; }, + // ---- What a shut panel says about itself ---- + // + // The sidebar is eight
      , and every one of them was exactly 60px tall with + // nothing in it but its own name: 480px of a phone's screen, and 480px of a 660px + // desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box + // balances, whether anyone is waiting for a seat, how many hands have been played — + // the page has all three worked out already, and every one of them cost a tap to + // find out, on a row two thirds of which was empty. + // + // That is the failure mode the disclosure pattern is warned about by name: a header + // you have to open to learn whether it was worth opening. Salesforce and Carbon both + // answer it the same way — put the status in the header — and NN/g's objection to + // accordions is exactly the cost of the taps this saves. + // + // So a shut panel now carries the one fact you would have opened it for. The panel + // itself still holds the sentence: "Off by $40" is the alarm, and which seat is + // holding chips nobody wrote down is a paragraph, which is the half that belongs + // behind the tap. + // + // Silence is a state too. A glance appears when it has something a dealer would act + // on and stays away otherwise — nobody waiting, no notes, no hands yet — because a + // row reading "Nobody waiting" all night is the clutter this is here to remove, and + // eight rows that always say something are eight rows that say nothing again. + PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting", + "glanceHands", "glanceChips", "glanceNotes"], + + // Pure, so the wording can be tested without a page to hang it on. Every value is + // read off the state the panel below it renders from, never off the panel's own + // DOM — a shut
      has no rendered body to read. + panelGlances(state) { + const glance = (text, tone = "") => ({ text: text || "", tone }); + if (!state) return {}; + + const blinds = state.smallBlind > 0 && state.bigBlind > 0 + ? ` · ${Money.format(state.smallBlind)}/${Money.format(state.bigBlind)}` + : ""; + const waiting = (state.waitingList || []).length; + const hands = (state.handHistory || []).length; + + // Denominations rather than a count of rows, because "are the values right for + // tonight's set" is the question, and five colours is the answer to a different + // one. Chips with no value on them aren't in the range: a blank row is a chip + // the dealer hasn't got to yet, not a zero-dollar chip. + const values = (state.chipLegend || []) + .map(chip => Number(chip.value) || 0).filter(v => v > 0).sort((a, b) => a - b); + const span = values.length + ? (values[0] === values[values.length - 1] + ? Money.format(values[0]) + : `${Money.format(values[0])}–${Money.format(values[values.length - 1])}`) + : ""; + const chips = values.length + ? `${values.length} ${values.length === 1 ? "colour" : "colours"} · ${span}` + : ""; + + // The first line that has anything on it. Notes get typed as a list — "Sam owes + // Dee 20" under a blank line under a heading — and the first line of the string + // is often not the first line of the note. + const notes = (state.notes || "").split("\n") + .map(line => line.trim()).find(Boolean) || ""; + + const bank = HoldemState.bankReconciliation(state); + // Before anybody has bought in there is nothing to reconcile, and the panel + // hides its own check for the same reason — so the glance saying "Balanced" + // over an empty table would be answering for a count that was never taken. + const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0; + + return { + glanceTableSetup: glance(`${state.seatCount} seats${blinds}`), + glanceStandings: nothingBanked ? glance("") + : bank.balanced ? glance("Balanced", "ok") + : glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"), + glanceWaiting: glance(waiting ? `${waiting} waiting` : ""), + glanceHands: glance(hands ? `${hands} ${hands === 1 ? "hand" : "hands"}` : ""), + glanceChips: glance(chips), + glanceNotes: glance(notes), + }; + }, + + renderPanelGlances() { + const glances = this.panelGlances(this.state); + // renderAll runs after every tap a dealer makes and none of this changes between + // most of them. Writing textContent is cheap; making the browser re-style eight + // rows for the same eight strings is the part worth not doing. + const signature = this.PANEL_GLANCES + .map(id => `${id}:${(glances[id] || {}).text}|${(glances[id] || {}).tone}`).join(";"); + if (signature === this.lastGlanceSignature) return; + this.lastGlanceSignature = signature; + + this.PANEL_GLANCES.forEach(id => { + const host = document.getElementById(id); + if (!host) return; + const { text, tone } = glances[id] || { text: "", tone: "" }; + host.hidden = !text; + host.textContent = text; + host.classList.toggle("panelGlanceOk", tone === "ok"); + host.classList.toggle("panelGlanceWarn", tone === "warn"); + }); + }, + // Misdeal is only a thing you can call on a hand that is out. Before the deal there // is nothing to take back, and afterwards the header would be offering to unwind a // hand that has already been paid — so the button comes and goes with the hand @@ -405,6 +505,43 @@ const Render = { // The step the search walks down in. Six pixels is finer than the difference between // two font sizes and coarse enough to settle in ten tries. SEAT_WIDTH_STEP: 6, + // And the ceiling when the felt has room to spare, which on anything tablet-sized or + // larger it does. 96px was the only ceiling there was, so the felt grew with the + // window and the seats did not: measured nine-handed, the pucks covered 19% of the + // felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond, with the + // stack figure a dealer reads sitting at 10px on a 2560px monitor. + // + // 144 rather than further. Past it the extra width buys nothing a dealer can use — + // at 144 a 15px "Mike from work" is already on one line, which is the longest thing + // a name is asked to be — and a puck much wider than its own contents stops reading + // as a seat and starts reading as a card. + SEAT_WIDTH_ROOMY_MAX: 144, + // Coarser above the ordinary ceiling, because up there the difference is 8% of the + // puck rather than the difference between two font sizes, and every candidate width + // costs a forced layout. + SEAT_WIDTH_ROOMY_STEP: 12, + // And the puck may not be more than a fifth of the shorter span of the ring. + // + // The containment arithmetic keeps a puck inside the table's *box*; the rail it + // looks like it should stay inside is an ellipse, and at the top and bottom of the + // ring the ellipse curves away from the box. Seats have always sat a little over + // the rail because of it — measured before any of this, 10px on a desktop and 46px + // on a phone held sideways — and the wider the puck, the wider that gap opens. + // + // Which made the first version of this a bad trade on exactly the screens it was + // supposed to leave alone. A 144px puck on an iPhone's 278px ring is half the width + // of the table: six-handed, all six sat over the rail by 15px against 5px before, + // and it read as a stack of cards rather than a table with people around it. + // + // A fifth is measured, not chosen. The shorter span is 248-318px on every phone in + // the sweep including sideways, and 672-978px on tablets and desktops, so any + // divisor between about 3.3 and 7 splits them; five sits in the middle with room + // either side. Above it the pass is skipped and a phone measures exactly the ten + // widths, and settles on exactly the puck, that it did before. + SEAT_ROOMY_SPAN_RATIO: 5, + // A puck past the ordinary ceiling gets the larger type, which is the point of the + // extra width — see `.seatsRoomy` in holdem.css. + SEAT_ROOMY_FROM_PX: 108, // Below this a puck cannot hold two lines of name and a row of hole cards, so the // typography drops to the tight set in one go. Width scales continuously; type // changes deliberately, which is the split the modern guidance actually draws — @@ -418,7 +555,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,30 +634,77 @@ 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) { layer.style.setProperty("--seatPuckW", `${width}px`); layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX); + layer.classList.toggle("seatsRoomy", width >= this.SEAT_ROOMY_FROM_PX); + }, + + // Is it worth asking about pucks wider than the ordinary ceiling? + // + // Arithmetic, not a measurement, and that is the whole point: closestSeatGapPx is + // the tightest centre-to-centre distance the seats will sit at on the widest ring + // they could have, so if that already clears the widest ordinary puck and its + // clearance, the felt has room going spare and the coarse pass above the ceiling + // will find some of it. If it does not — every phone in the sweep, at every seat + // count — the pass is skipped and exactly the ten widths that were measured before + // are measured now. + // + // Worth being strict about, because the candidates are not free. Raising the ceiling + // for everybody took the layout pass from 24ms to 73ms on a twelve-handed desktop, + // and it is the phones, which would gain nothing at all, that can least afford it. + roomForWiderPucks(count, box) { + if (!box) return false; + if (this.roomyWidthCeiling(box) <= this.SEAT_WIDTH_MAX) return false; + const gap = this.closestSeatGapPx(count, box, this.seatRingRadii()); + return gap > this.SEAT_WIDTH_MAX + this.SEAT_CLEARANCE_PX; + }, + + // The widest a puck may be drawn on this felt, whatever the seats would allow. + roomyWidthCeiling(box) { + if (!box || !box.width || !box.height) return this.SEAT_WIDTH_MAX; + const span = Math.min(box.width, box.height) / this.SEAT_ROOMY_SPAN_RATIO; + return Math.min(this.SEAT_WIDTH_ROOMY_MAX, Math.floor(span)); }, // Widest first, because the widest that fits is the one we want. - seatWidthCandidates() { + seatWidthCandidates(count, box) { const widths = []; + if (count != null && this.roomForWiderPucks(count, box)) { + const ceiling = this.roomyWidthCeiling(box); + for (let w = ceiling; w > this.SEAT_WIDTH_MAX; w -= this.SEAT_WIDTH_ROOMY_STEP) { + widths.push(w); + } + } for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) { widths.push(w); } @@ -531,9 +715,26 @@ const Render = { // What the table has changed about the shape of its pucks. Anything that can make a // name wrap to a second line belongs here, because the height that comes back from // measuring is what the ring is sized against. + // + // The hole-card line and the amount pill belong here for the same reason, now that + // an empty one collapses instead of holding its space: a puck with either of them + // filled is about 15px taller than the same puck without, and a ring still sized + // from the measurement taken before the first chips went in is sized for a puck + // that no longer exists. + // + // Booleans over the whole table rather than a list per seat, because that is what + // the measurement is — measuredSeatBox takes the tallest puck on the ring, so what + // matters is whether *anybody* has one. A boolean also flips about twice a hand + // rather than on every action, and each flip costs ten forced layouts, which is the + // expense this cache exists to avoid. seatShapeSignature() { if (!this.state) return ""; - return `${this.state.seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}`; + const seats = this.state.seats; + const anyCards = seats.some(p => (p.cards || []).some(Boolean)); + const anyAmount = seats.some(p => Betting.handTotal(p) > 0 || + HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0); + return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` + + `|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`; }, // Which width the pucks get, and how wide a ring they sit on. @@ -586,7 +787,7 @@ const Render = { } const candidates = []; - for (const width of this.seatWidthCandidates()) { + for (const width of this.seatWidthCandidates(count, box)) { this.applySeatWidth(layer, width); const puck = this.measuredSeatBox(); if (!puck) { @@ -1135,19 +1336,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 +1389,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) { @@ -1698,6 +2046,12 @@ const Render = { // after naming a player both stayed greyed out, still captioned "nobody is at // the table yet", because this path deliberately doesn't redraw everything. this.renderHeaderActions(); + // Third thing on this list for the third time the same reason. What a seat has + // bought in for and what it is holding are the two halves of the bank check, so + // this path is the one that moves "Balanced" to "Off by $300" — and the shut + // Standings panel is where a dealer would see it, since the seat editor is over + // the sidebar while they are typing. + this.renderPanelGlances(); }, wireSeatFields(form, player, index) { @@ -1846,25 +2200,52 @@ 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(); + // This is a named list of everything a chip's value moves, and the + // legend's own summary is on it: shut, the panel reads "5 colours · + // $1–$500", and re-pricing the top chip is exactly the edit that + // changes it. Called here for the same reason as the rest of the list: + // renderAll would write this field's own value back into it mid- + // keystroke. + // + // Only that one. The bank check is not downstream of this, which is + // worth saying because it looks as though it should be — a seat's chip + // counts are priced by the legend for the puck and the seat editor's + // total, but bankReconciliation asks chipsHeldBy, which is stackOf, and + // stackOf reads the seat's typed stack or the table's default and never + // opens the legend at all. + this.renderPanelGlances(); }); - 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); }); @@ -1923,7 +2304,15 @@ const Render = { // Shared by the action bar (live, mid-hand) and the pot wizard (before winners are // picked), because both are showing the dealer the same announcement. - buildPotBreakdown(pots, returns, nameFor, heading) { + // + // `maxLayers` caps how many pots get a line of their own, and only the bar passes + // it. The bar is fixed over the felt, so nothing in it may grow with the table — + // and this list did, one row per all-in, with no ceiling. Measured on a 360x640 + // phone: five players idle put the bar at 151px, six all-in at 303px, and twelve + // all-in at 411px, which is 64% of the screen and leaves 96px of felt for twelve + // seats. The wizard passes no cap: it is a dialog with room, opened at the point + // the whole list is what you are working from. + buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers } = {}) { if (pots.length < 2 && returns.length === 0) return null; const wrap = document.createElement("div"); @@ -1936,7 +2325,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 || ""}`; @@ -1951,6 +2340,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); @@ -1958,12 +2352,74 @@ const Render = { wrap.appendChild(row); }; - pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", "))); + // The two ends of the list are the live ones, and they are live for the same + // reason: pots are paid from the newest side pot back down to the main pot, so + // the top layer is the next thing that happens and the main pot is the last. + // Those are also the two a dealer says out loud while the chips are being + // stacked — "main pot 7,200, side pot 4,500". The layers in between are the + // middle of that count-down, read at showdown rather than mid-hand. + // + // Elide only when it buys something: at the cap itself the middle is one row + // and replacing one row with one row is pure indirection, so the whole list + // stands until there are strictly more pots than the cap. + const middle = maxLayers && pots.length > maxLayers ? pots.slice(1, -1) : []; + const shown = new Set(middle.length ? [pots[0], pots[pots.length - 1]] : pots); + + pots.forEach(pot => { + if (!shown.has(pot)) return; + addRow( + pot.label, pot.amount, + this.describeEligible(pot.eligible, pots[0].eligible, nameFor), + "", + pot.eligible.map(nameFor).join(", ")); + // Kept in place rather than appended, so the amounts still read in the + // order the pots were made and the gap sits where the gap actually is. + if (middle.length && pot === pots[0]) { + addRow( + `${middle.length} more side pots`, + middle.reduce((sum, layer) => sum + layer.amount, 0), + "listed when you resolve", + "potLayerMore", + middle.map(layer => `${layer.label} ${Money.format(layer.amount)}`).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/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 4b97663..10706e2 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -22,6 +22,11 @@ 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 | +| `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 @@ -38,19 +43,46 @@ 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 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 @@ -68,6 +100,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 @@ -107,6 +159,418 @@ 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. + +## A property the wording depends on + +`potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it +leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The +main pot is everyone still live, and each side pot above it drops whoever was all-in +below, so every side pot's players are a strict subset of the pot beneath it. + +That makes "who is in" and "who is out" complements, which is what lets the line name +whichever of the two is shorter and never spell out more than half the table. Listing +every eligible player instead meant the main pot's line was every name at the table and +each side pot repeated it minus one — and the lines too long to fit were exactly the ones +nobody needed, while the short ones at the bottom were the side pots a dealer actually +announces by name. + +The nesting check runs 20,000 random tables, and it asserts on its own generator as well +as on the code: if a change to the shapes stopped producing side pots, every assertion in +the loop would be vacuous and the test would still pass, so it also requires that more +than ten thousand of those tables had one. + +Writing that check is where a comparison against `Set.length` went in — a `Set` has +`.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an +assertion it failed everything; as a filter in a throwaway script it had passed +everything, which is how it survived long enough to be written down. Both readings are +silent. Prefer asserting the count you expect over comparing two things whose shapes you +have not checked. + +## Testing a fault that broke nothing + +`sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because +the fault it covers was not a bug. Every control worked and every id resolved. What was +wrong was which panel a thing was in and what that panel was called. + +The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and +Session held the standings, a waiting list, the hand log and the save/load/reset buttons +— a topic, a task, a log and three utilities under one word. It measured 1206px and 103 +visible elements against 555 and 29 for the next biggest, and "The table itself" sat +1052px down inside it, so reaching Reset table meant opening a panel called Session and +scrolling past the money and the log to find it. + +Three things are asserted, and each of them is a claim a future edit could quietly undo: + +- **The names say what is inside them.** The list is checked literally, because the + failure mode is a word like "Session" that could hold anything. +- **Each name is a heading.** A `` is announced as a button, so the sidebar's + names — its whole navigation — were not reachable by heading. Splitting Session also + removed the four subheads that had been the only headings down there, so without a + heading in each summary the page would have come out of this with a worse outline than + it went in with. +- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is + legitimately long. It counts *subjects*: a panel that needs its own headings to list + its parts is holding more than one thing. Two subheads survive, and the test names + them, because Settle up is the standings at the end of the night and Stacks in chips + is the chip legend counted out — the same subject at a different moment, which is what + a subhead is for. + +The panels are split on the opening tag rather than parsed, which is sound only because a +sidebar panel never contains another one; Table Setup's sub-panels carry a different +class. That assumption is worth knowing about if one ever does. + +## A warning is only worth having if its number is right + +`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled +24px button and was never a change to a legend. `computeContribution()` prices a seat by +running the legend over that seat's chip counts, so a colour that leaves takes every chip +counted in it off the table. Measured in a browser on a three-handed table: deleting the +$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said +afterwards, and no way back — a colour added again is a new colour with a new id, while +the counts stay keyed to the one that left. + +Most of the file is about the number in the warning rather than the removal, because a +warning that overstates is worse than none. The obvious way to work it out — chips times +value — is wrong in **both** directions: + +- a seat with a typed amount is priced from that and its counts are never read, so a + colour it holds is worth nothing to it; +- a seat whose chips all leave falls through to the table's standing ante rather than to + zero, so it loses less than its chips were worth. + +So the figure is the difference between pricing every seat with the colour and without +it, and a randomised check across 2,000 tables asserts that what the warning claims is +exactly what the table then does. That check found a third case nobody would have written +by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table +**up** $186. The wording says so in that direction too. + +The friction is conditional on purpose. A colour nobody has counted deletes on one click; +only one that is holding money asks. A confirmation that fires every time is one nobody +reads by the third night, and this is a tool where four other confirmations already +compete for that attention. + +## Every control says what it is + +`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the × +had no accessible name, so it was announced as a button and nothing else — five of them +in a column, identical, each deleting a different colour. + +It treats a symbol as no name even though `×` is technically text. The rule is that a +name has to contain a letter or a digit, which is the difference between "×" and "Undo". +Names are resolved the way the accessibility tree does it — `aria-label`, then +`aria-labelledby`, then a field's wrapping or pointing `