Hold'em dealer: making the table and the bar fit the screen they are on #119

Merged
jheaps merged 11 commits from polish/holdem-round-next into master 2026-09-08 19:08:13 -06:00
23 changed files with 4247 additions and 121 deletions
+3 -1
View File
@@ -366,4 +366,6 @@ MigrationBackup/
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
native/**/build/
native/**/build/
# Playwright MCP scratch output
.playwright-mcp/
+69 -33
View File
@@ -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. *@
<div id="holdemHeaderLeft">
@@ -67,7 +68,7 @@
loaded: open on a table nobody has set up yet, shut on one already
half-way through a night. *@
<details class="sidebarPanel" id="tableSetupPanel">
<summary>Table Setup</summary>
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
@@ -145,11 +146,23 @@
</div>
</details>
@* 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. *@
<details class="sidebarPanel">
<summary>Session</summary>
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
<div class="panelBody">
<p id="storageWarning" class="storageWarning" hidden></p>
<h3 class="panelSubhead">Standings</h3>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
@@ -165,14 +178,6 @@
</div>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
<h3 class="panelSubhead">Waiting list</h3>
<form id="waitingListForm" class="waitingAdd">
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
</form>
<ol id="waitingList" class="waitingList"></ol>
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
@@ -203,8 +208,24 @@
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
</div>
</details>
<h3 class="panelSubhead">Hands</h3>
<details class="sidebarPanel">
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
<div class="panelBody">
<form id="waitingListForm" class="waitingAdd">
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
</form>
<ol id="waitingList" class="waitingList"></ol>
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
<div class="panelBody">
<div class="handHistoryFilterRow">
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
@@ -218,27 +239,11 @@
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
<h3 class="panelSubhead">The table itself</h3>
<p class="fieldHint">
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.
</p>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
@* 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. *@
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
@@ -253,7 +258,7 @@
</details>
<details class="sidebarPanel" id="keyboardPanel">
<summary>Keyboard</summary>
<summary><h2>Keyboard</h2></summary>
<div class="panelBody">
<dl id="shortcutLegend" class="shortcutLegend"></dl>
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
@@ -261,11 +266,37 @@
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
@* 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. *@
<details class="sidebarPanel">
<summary><h2>Save &amp; reset</h2></summary>
<div class="panelBody">
<p class="fieldHint">
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.
</p>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
</div>
</div>
</details>
</aside>
</div>
</div>
@@ -641,6 +672,11 @@
<link rel="manifest" href="/casino/holdem.webmanifest" />
@* iOS reads none of the manifest's icons and only this. *@
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
@* The standard name first, then Apple's older one. Chrome has been printing
"<meta name="apple-mobile-web-app-capable"> 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. *@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hold'em" />
+646 -22
View File
@@ -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 <summary> 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 <summary> 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);
}
@@ -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 }
);
},
@@ -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`);
},
};
@@ -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 <span>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);
[
+495 -39
View File
@@ -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 <details>, 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 <details> 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() {
@@ -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
+465 -1
View File
@@ -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 `<summary>` is announced as a button, so the sidebar's
names — its whole navigation — were not reachable by heading. Splitting Session also
removed the four subheads that had been the only headings down there, so without a
heading in each summary the page would have come out of this with a worse outline than
it went in with.
- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is
legitimately long. It counts *subjects*: a panel that needs its own headings to list
its parts is holding more than one thing. Two subheads survive, and the test names
them, because Settle up is the standings at the end of the night and Stacks in chips
is the chip legend counted out — the same subject at a different moment, which is what
a subhead is for.
The panels are split on the opening tag rather than parsed, which is sound only because a
sidebar panel never contains another one; Table Setup's sub-panels carry a different
class. That assumption is worth knowing about if one ever does.
## A warning is only worth having if its number is right
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
24px button and was never a change to a legend. `computeContribution()` prices a seat by
running the legend over that seat's chip counts, so a colour that leaves takes every chip
counted in it off the table. Measured in a browser on a three-handed table: deleting the
$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said
afterwards, and no way back — a colour added again is a new colour with a new id, while
the counts stay keyed to the one that left.
Most of the file is about the number in the warning rather than the removal, because a
warning that overstates is worse than none. The obvious way to work it out — chips times
value — is wrong in **both** directions:
- a seat with a typed amount is priced from that and its counts are never read, so a
colour it holds is worth nothing to it;
- a seat whose chips all leave falls through to the table's standing ante rather than to
zero, so it loses less than its chips were worth.
So the figure is the difference between pricing every seat with the colour and without
it, and a randomised check across 2,000 tables asserts that what the warning claims is
exactly what the table then does. That check found a third case nobody would have written
by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table
**up** $186. The wording says so in that direction too.
The friction is conditional on purpose. A colour nobody has counted deletes on one click;
only one that is holding money asks. A confirmation that fires every time is one nobody
reads by the third night, and this is a tool where four other confirmations already
compete for that attention.
## Every control says what it is
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
had no accessible name, so it was announced as a button and nothing else — five of them
in a column, identical, each deleting a different colour.
It treats a symbol as no name even though `×` is technically text. The rule is that a
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
Names are resolved the way the accessibility tree does it — `aria-label`, then
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
skipped rather than judged, since it is not in the tree to begin with.
Removing the one `aria-label` this round added makes it report all five buttons, which is
how it was checked.
## Where the keyboard lands
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
not be completely hidden by something the page has put on top of it. Tabbing the whole
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
under the action bar; on the desktop, four more under a toast.
They are checked as the fix rather than as the symptom, because the symptom needs a
keyboard and this file has no automation of its own:
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
least as much room as the fixed furniture standing at the bottom of the window. Only
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
down the right-hand side would demand scroll padding the height of the whole window;
and sticky elements inside a panel that scrolls on its own are excluded, because
scroll-padding on the document would do nothing about them.
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
### Half a fix looks exactly like a whole one
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
applies when the browser has to scroll to reach the control at all; a control the browser
already considers visible is one it will not scroll to, and a fixed overlay on top of it
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
outlast an eight-second toast.
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
and, when the page has no room left to scroll (26px available against 94px wanted, at
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
well would have fixed it by making the page grow and shrink by 64px every time anything
was announced, which is worse than the fault.
## A sweep only measures the states it reaches
The action bar is fixed over the felt, so its height is the felt's budget — and the bar
carries the side-pot breakdown, which drew one row per all-in with no ceiling. Measured
on a 360x640 phone: five players idle put the bar at **151px**, six all-in at **303px**,
and twelve all-in at **411px** — 64% of the screen, leaving **96px of felt** to draw
twelve seats in.
Nothing here could see it, and the reason is worth writing down. Every check in
`uiAudit.js` asks whether some element is covered, clipped or unreachable; not one asked
how much room the covering element had taken. But the deeper miss was the sweep itself:
all 294 audits drove their table with `Call` and `Check`, so **no table in the history of
this file ever had a player all-in**, and the one thing on the bar whose height depends
on the table was empty in every single measurement. `runUiAudit.js` now has a fourth
shape, twelve players shoved in for twelve different amounts — there is no one-tap shove,
so it takes the raise pad's All-in preset and confirms, or `Call all-in` for anyone who
cannot cover.
- **`furnitureBudget()`** — the fixed bottom band, measured against the window. Failing
above **50%**, which is the line that needs no taste to defend: furniture larger than
the content it serves is a shape fault whatever it is showing. Warning above **30%**,
the published guidance for persistent chrome, which the shortest phone does not meet at
rest — the bar is 151px of a 640px screen before anything happens — and a sweep that is
always red stops being read.
### Cutting the middle out of a list, and which end to keep
The cap is four layers, and past it the bar draws the main pot, one line for everything
between, and the newest side pot. The two ends are not an arbitrary choice: side pots are
paid **from the newest one back down to the main pot**, so the top layer is the next thing
that happens and the main pot is the last, and those are exactly the two a live dealer
says out loud while the chips are being stacked — *"main pot 7,200, side pot 4,500"*. The
layers between are the middle of that count-down, wanted at showdown, which is the pot
wizard — the same builder, passing no cap, in a dialog that scrolls.
`potBreakdownRoom.test.js` checks the parts that could go wrong quietly:
- the row count is a function of the cap, not of the table — eight, nine, ten, eleven and
twelve all-in all draw three lines
- the drawn amounts still add up to every pot, so a dealer counting the announcement
against the chips in the middle finds no shortfall the tool caused
- the elision sits **where the gap is**, not appended after the list, so the amounts still
read in the order the pots were made
- it never fires when it would not buy anything: at exactly the cap the middle is one row,
and swapping one row for one row loses an amount and gains a sentence
Three injected faults — the cap ignored, the summary carrying only the first elided pot's
money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests
respectively, and the audit check reports the original 64% when the cap is taken out.
## Two empty rows were setting how far apart twelve seats had to sit
Every seat puck carries four rows: the name, the hole cards, the amount that player has
put in, and their stack. Two of those are empty most of the time, and both held their
space anyway.
The amount pill was worse than empty space. It has a background, a pill radius and side
padding, so with no text it painted an **8x11px green lozenge** — measured on **seven of
nine seats** at an ordinary table. One of those is a component holding its place; nine is
a rash, and a dealer has to learn to ignore a colour that on the other two seats means
"this player has money out".
The space was not free either. `seatRingRadii` sizes the ring from the tallest puck on
it, so two rows nobody had filled were setting the spacing for the whole table:
| | before | after |
| --- | --- | --- |
| iPhone 14, 9 seats | 78x88 puck | **78x63** |
| iPhone 14, 12 seats | 44x65 | **66x55** |
| Pro Max, 9 seats | 84x88 | **96x63** |
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
- **`emptyDecoration()`** — nothing inside `#pokerTableWrap` may paint a background or a
border with no content in it. The rule is about paint, not space: an element may hold
its place invisibly, and several do. With the old reservation restored it reports all
seven pills; live it reports none.
The one exemption, `.betChipDisc`, is on the list because the check reported it, not
because it was predicted — it is a 13x13px circle filled with the denomination colour of
the chip beside it, so the colour *is* the content. Three other exemptions were written
from memory first and matched no element on the page at all, which is the argument for
building that list from a run.
### The collapse is only safe because the cache key moved
`resolveSeatLayout` measures ten candidate widths, each one a forced layout, and caches
the result against `seatShapeSignature()`. That signature listed the names and the dealer
seat — everything that could wrap a name onto a second line. It did not mention the two
rows, because they always held their space and so could never change a puck's height.
Once they collapse, they can. `seatPuckShape.test.js` checks the key moves when the first
chips go in and when a hole card is entered, and — just as important — that it does *not*
move for the second player to bet, or when an amount merely gets wider. It is a boolean
over the whole table because that is what the measurement is: `measuredSeatBox` takes the
tallest puck on the ring, so what matters is whether anybody has one. A per-seat key would
be correct and would throw away the cache on every single tap.
Measured on the running page: the ring settles by 24-26px when the blinds are posted —
and it settled by **23-25px there before this round too**, because the action bar changes
height at that moment and the felt is measured against it. The one place the ring moves
is a place it already moved.
### A test that passed for the wrong reason
The signature joins names on a control character so that "ann" + "abel" and "anna" + "bel"
stay distinguishable. The first version of that test used `Ann`/`Abel` against
`Anna`/`Bel` — which can never collide whatever the separator, because the capitals land
in different places. It passed with the separator deleted and proved nothing. Lower case
is what makes it a test. Four faults were injected in total; the other three (dropping the
two flags, letting the count into the key instead of a boolean, and counting only hand
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
## Room the felt had and the type never got
The felt grows with the window and the pucks did not — 96px was the only ceiling there
was. Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop
and **8% at 1440px and beyond**, with the stack figure a dealer reads at **10px on a
2560px monitor**, the same 10px as on a 360px phone.
Two things stop the fix costing more than it buys, and `seatWidthSearch.test.js` checks
both:
- **Every candidate width is a forced layout.** Raising the ceiling for everybody took
the layout pass from 24ms to 73ms twelve-handed on a desktop. A screen that cannot use
the wider widths must not measure them, so a phone is checked to measure *exactly* the
ten widths it measured before — same list, same order — and the measured cost on a
phone is unchanged (10.7ms against 10.4ms, which is noise).
- **A wide puck on a narrow felt is worse than a small one.** The first version let
phones through, and a 144px puck on an iPhone's 278px ring is half the width of the
table: six-handed, all six seats sat over the rail by 15px against 5px before, and it
read as a stack of cards rather than a table with people around it.
So the ceiling is a fifth of the felt's **shorter** span, capped at 144. Five is measured,
not chosen: the shorter span is 233-303px on every phone in the sweep including sideways,
and 617-714px on tablets and desktops, so any divisor between 3.2 and 6.4 separates them.
The test pins both ends, so if the felt ever changes shape enough to bring those together
it says so rather than letting the phones quietly start paying.
Above the ordinary ceiling the puck also takes the larger type — `.seatsRoomy`, two steps
up the scale for the two numbers read across the table and one for the chair number.
Steps rather than a `clamp()`, for the same reason the tight set is: the width is a
continuum and the type is a set of deliberate sizes.
| nine-handed | before | after |
| --- | --- | --- |
| iPad puck / name | 96px / 12px | **134px / 15px** |
| desktop 1440 puck / name | 96px / 12px | **140px / 15px** |
| seats as a share of the felt, 1440 | 8% | **12%** |
| every phone, every seat count | unchanged | unchanged |
### What the rail check is actually measuring, which is not the rail
Found while checking the above and **not fixed** — written down here because the number is
worth having and the fix is not a small one.
`#pokerTable` is `border-radius: 50%`. Both the settle pass in `render.js` and
`seatsOnTheTable()` in the audit compare a puck against the four edges of the table's
**bounding box**, which is a different and much slacker question than the one the message
"hangs Npx past the rail" claims to answer: at the top and bottom of the ring the oval
curves away from its own box, so a puck can be comfortably inside the box and visibly
outside the felt.
Measured against the ellipse instead — a point is outside when `(dx/a)² + (dy/b)² > 1`,
and the ray from the centre crosses the boundary at `1/√t` of the way out — across 35
configurations the check is seeing **303px of overhang as zero**, including **46px on a
phone held sideways** that has been there for as long as the check has.
Settling against the ellipse was tried and reverted, because it is not the local fix it
looks like. Pulling the ring in far enough to clear the oval crowds the seats, and on a
twelve-handed phone with everybody all-in it put seat 3's centre inside seat 4 — the one
trade the width search explicitly refuses to make, since a dealer can see a puck over the
rail but cannot see a tap answering for the wrong player. Guarding against that instead
leaves the ring out and the overhang worse: the sweep went from 6 failing configurations
to 77, with 355 rail reports.
Which is the finding. On a crowded table **keeping the seats off the rail and keeping them
off each other are in genuine conflict**, and resolving it needs the two settled together
rather than one bolted onto the other.
### And then measured again, and dropped
Re-probed across 40 configurations — four table shapes at ten viewports — the gap is real
and systematic: **31 of 40 sit further past the oval than the box says**, worst 34px
against a box overhang of 0 everywhere. The 303px above was the all-in shape, which is the
extreme rather than the case.
Then it was looked at rather than measured, and 12–17px of a rounded puck's corner
crossing the rail reads as players *at* a rail — which is what the thing is a picture of.
The measurement gap is worth writing down and the appearance is not worth a round, so the
lead is closed rather than carried forward. Worth the re-measure to find that out: the
number that made it look urgent was the worst cell in the table, not a typical one.
## Eight rows that said only their own names
The sidebar is eight `<details>`, and every one measured exactly 60px shut with nothing on
it but its label — 480px of a phone's screen, and 480px of a 660px desktop column, spent
saying "Standings", "Hands", "Waiting list". Whether the box balances, whether anyone is
waiting for a seat, how many hands have been played: the page had **all three worked out
already**, and charged a tap for each of them, on rows two thirds of which were empty.
That is the disclosure pattern's named failure — a header you have to open to find out
whether it was worth opening — and the answer the design systems give is the same one:
put the status on the header. So each shut panel now carries the one fact you would have
opened it for, and the panel keeps the sentence. "Off by $300" is the alarm; *which seat
is holding chips nobody wrote down* is a paragraph, and that is the half that belongs
behind the tap.
Two rules hold it together, and `panelGlance.test.js` checks both.
**A glance appears only when it has something a dealer would act on.** Eight rows that
always say something are eight rows that say nothing again, and a row reading "Nobody
waiting" all night is the clutter this went in to remove. So no waiting list, no notes and
no hands yet are all silence, and the bank check stays quiet over a table nobody has
bought into — which is what the panel does with its own check, for the same reason.
**A glance never says something the panel under it would contradict.** It is read off the
same state, never off the panel's DOM — a shut `<details>` has no rendered body to read —
and one test walks three buy-in figures asserting the glance's colour agrees with
`bankReconciliation`'s verdict and its number carries the same difference.
The layout half is `glancesFitTheirRows` in the audit, and it took three goes.
The first version asked only whether a row had grown taller than a row with nothing on it,
which sounds like the whole question and is not: a glance floated back into flow and
allowed to wrap **sailed straight through it**, because a float does not expand the box it
sits in — two lines of note hung out of a row that still measured exactly 44px. Asking
where the glance actually ended up, in both axes, is the question that has an answer.
The second version was wrong in the other direction, and **the sweep caught it on its
first clean run**: 94 failing configurations, 91 of the offenders this check's own. It
takes the shortest row in the sidebar as its idea of a bare one, and the Keyboard panel is
`display: none` under `pointer: coarse` — a touch device has no keys to press — so on
every viewport visited with a thumb the bar was a row that wasn't drawn at all, 0px, and
all seven real rows read as grown. Seven rows times thirteen viewports is exactly the 91.
Rows that aren't rendered aren't rows.
Which is the point of running the sweep against a check as well as against the page. A
narrowing fix has to be shown not to have blinded the thing it quietened, so the four
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
breaking it — and each is still caught, while the unbroken page stays clean.
## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody
+183
View File
@@ -0,0 +1,183 @@
// Taking a chip colour out of the legend.
//
// This was one click of an unlabelled 24px button, and it was 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 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. The counts were left behind too, so the saved file carried money
// that nothing could price and nothing could show.
//
// What is tested here is mostly the number in the warning, because a warning that
// overstates is worse than none: it is the one thing a dealer would have to trust at
// the exact moment they are about to lose track of the box.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Money } = require("./load");
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
state.basePotAmount = basePot;
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = 500;
state.seats[i].chipCounts = {};
});
counts.forEach(([seat, chipIndex, n]) => {
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
});
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
return state;
};
const onTable = state => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
// ---------------------------------------------------------------------------
describe("what a chip colour is holding up", () => {
test("is the money, named seat by seat", () => {
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
assert.equal(usage.total, 350);
assert.equal(usage.chips, 14);
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
["Christopher", 4, 100],
["Mike from work", 10, 250],
]);
});
test("is nothing at all for a colour nobody counted", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
assert.equal(usage.total, 0);
assert.deepEqual(usage.seats, []);
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
});
test("and nothing for a colour that isn't there", () => {
const state = tableWith();
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
{ chip: null, total: 0, chips: 0, seats: [] });
});
});
describe("the number in the warning is the number the table loses", () => {
// The whole point. Multiplying the chips by their value would have been the obvious
// way to work this out and it is wrong in both directions — the two tests below are
// the two ways it is wrong.
const dropFromRemoving = (state, chipId) => {
const before = onTable(state);
HoldemState.removeChipColor(state, chipId);
return Money.round(before - onTable(state));
};
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
// computeContribution() reads amountOverride first and never reaches the counts.
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
const chipId = state.chipLegend[2].id;
const usage = HoldemState.chipColorUsage(state, chipId);
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
assert.equal(dropFromRemoving(state, chipId), 0);
});
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
// The $25s are everything Christopher has counted, so taking them away drops him
// to the table's standing ante — a loss of 50, not of 250.
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
const chipId = state.chipLegend[2].id;
const usage = HoldemState.chipColorUsage(state, chipId);
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
assert.equal(dropFromRemoving(state, chipId), 50);
});
test("and across a spread of tables the two always agree", () => {
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const random = rng(20260906);
let withMoney = 0;
for (let table = 0; table < 2000; table++) {
const counts = [];
for (let seat = 0; seat < 3; seat++) {
for (let chip = 0; chip < 5; chip++) {
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
}
}
const overrides = [];
for (let seat = 0; seat < 3; seat++) {
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
}
const basePot = random() < 0.5 ? 200 : null;
const chipIndex = Math.floor(random() * 5);
const stated = tableWith({ counts, overrides, basePot });
const chipId = stated.chipLegend[chipIndex].id;
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
if (claimed !== 0) withMoney++;
assert.equal(claimed, actual,
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
JSON.stringify({ counts, overrides, basePot, chipIndex }));
}
// Guards the generator: shapes that never put money on a colour would make every
// assertion above compare zero with zero.
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
});
test("and it can be a gain, which is said as one", () => {
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
// stops being priced from its chips and falls through to the ante, so the table
// goes *up* by 186. Found by the randomised check above, not by hand.
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
assert.equal(usage.total, -186);
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
});
});
describe("removing it", () => {
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
const chipId = state.chipLegend[2].id;
HoldemState.removeChipColor(state, chipId);
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
"a count against a colour no legend entry answers to is money nothing can show"));
// The other colour on that seat is untouched.
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
});
test("reports what it cost, measured before anything moved", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
assert.equal(usage.total, 100);
assert.equal(usage.seats.length, 1);
});
test("and a colour that isn't there changes nothing", () => {
const state = tableWith({ counts: [[0, 2, 4]] });
const before = onTable(state);
const usage = HoldemState.removeChipColor(state, "no-such-chip");
assert.equal(usage.chip, null);
assert.equal(state.chipLegend.length, 5);
assert.equal(onTable(state), before);
});
});
+3 -1
View File
@@ -105,9 +105,11 @@ describe("the tokens themselves", () => {
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
// Published from JS at runtime rather than declared here — see actionBar.js for
// the first two, render.js for the seat width and the chip's denomination colour,
// which is picked per chip from the amount on it.
// which is picked per chip from the amount on it, and feedback.js for the
// toast's height, which is one line or two depending on what is being said.
const fromScript = new Set([
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
"--toastHeight",
]);
// Defined by the site's own stylesheet, not this one.
const fromSite = new Set(["--font-mono"]);
+165
View File
@@ -0,0 +1,165 @@
// What shape the felt ends up, which is a question about which rule wins.
//
// feltRoom.test.js covers how much room there is. This covers what the stylesheet does
// with it, and it exists because of a bug that no amount of reading caught: the upright
// oval for phones was written into the `max-width: 700px` block, and the `max-width:
// 900px` block below it set the same custom property back to 1. Both queries match a
// phone, the two declarations have identical specificity, and the later one wins. The
// rule was correct, the arithmetic was correct, and the property was 1 on every phone
// on the site. It measured as a no-op and would have shipped as one.
//
// So this walks the media queries the way the cascade does — in source order, keeping
// the last one that matches — instead of trusting that a rule written for a viewport is
// the rule that viewport gets.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so
// they come out before anything here counts declarations.
const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
// --- just enough of a media query engine to answer "does this viewport match?" -------
//
// Only the forms this stylesheet actually uses: width and height bounds, orientation,
// and `and` between them. Anything else is refused loudly rather than guessed at, so a
// new kind of query can't quietly start matching everything.
const matches = (condition, view) => {
if (!condition) return true;
return condition.split(/\s+and\s+/).every(part => {
const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/);
assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`);
const [, feature, raw] = m;
const px = parseFloat(raw);
switch (feature) {
case "min-width": return view.width >= px;
case "max-width": return view.width <= px;
case "min-height": return view.height >= px;
case "max-height": return view.height <= px;
case "orientation":
return raw === (view.width > view.height ? "landscape" : "portrait");
default:
assert.fail(`feltShape doesn't know the media feature "${feature}"`);
}
});
};
// Every style rule in the file, in source order, with the media condition it sits
// under. Written as a brace walk rather than a regex because a regex for `max-height`
// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a
// media feature is not a declaration.
const rules = () => {
const out = [];
const open = [];
let prelude = "", i = 0;
while (i < SOURCE.length) {
const c = SOURCE[i];
if (c === "{") {
const head = prelude.trim();
prelude = "";
if (head.startsWith("@")) { open.push(head); i++; continue; }
let depth = 1, j = i + 1;
while (j < SOURCE.length && depth > 0) {
if (SOURCE[j] === "{") depth++;
else if (SOURCE[j] === "}") depth--;
j++;
}
const media = open.filter(h => h.startsWith("@media"))
.map(h => h.replace(/^@media\s*/, "")).join(" and ");
out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) });
i = j;
continue;
}
if (c === "}") { open.pop(); prelude = ""; i++; continue; }
prelude += c;
i++;
}
return out;
};
const ALL_RULES = rules();
// The cascade, for equal-specificity declarations of one property on one selector: the
// last one whose query matches is the one the viewport gets.
const winnerAt = (selector, prop, view) => {
const winning = ALL_RULES
.filter(r => r.selector === selector && matches(r.condition, view))
.map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)))
.filter(Boolean)
.pop();
assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`);
return winning[1].trim();
};
const PHONES = [
{ width: 360, height: 640, name: "Galaxy S8" },
{ width: 375, height: 667, name: "iPhone SE" },
{ width: 390, height: 844, name: "iPhone 14" },
{ width: 412, height: 915, name: "Pixel 7" },
{ width: 430, height: 932, name: "Pro Max" },
];
// ---------------------------------------------------------------------------
describe("the shape a phone gets", () => {
test("is an oval standing up, on every one of them", () => {
PHONES.forEach(phone => {
const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone));
assert.ok(aspect > 1.2,
`${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` +
`tall and this is what uses it; anything at or under 1 is the square ` +
`felt that threw the room away.`);
});
});
test("and a wide screen still gets one lying down", () => {
// The desktop felt is the shape it always was. Whatever the phones do, this is
// the one a poker table looks like when there is room for it.
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72);
});
test("with the middle widths in between left round", () => {
// 701-900px is one column and wide enough that neither oval is called for. It
// has to be written as a range: a bare max-width would match the phones too and,
// sitting lower in the file, would win there.
[{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => {
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1);
});
});
});
describe("what stops the oval", () => {
const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 });
test("is the room and nothing but the room", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
});
test("and it is not floored, however tempting that is", () => {
// A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here
// and measured out again. Twelve players all in with nine side pots is the
// tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored
// to 242 the felt keeps its shape and hides five of the twelve seats behind the
// bar. Unfloored at 142 it is flat and all twelve are visible.
//
// The slit it was defending against wasn't reachable: that came from forcing
// --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96.
assert.doesNotMatch(phoneRule, /max\(/,
"a floor here trades a flat table for a covered seat, which is the worse of " +
"the two — see the comment above the rule");
});
test("and the width is let go of, so the height can be the one that binds", () => {
// The wide layout sizes the felt from its width and caps that. On a phone the
// width is the column and the height is what's scarce, so the cap moves.
assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none");
});
});
+218
View File
@@ -0,0 +1,218 @@
// What a shut sidebar panel says about itself.
//
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
// whether anyone is waiting for a seat, how many hands have been played: the page had
// all three worked out and charged a tap for each of them, on rows two thirds empty.
//
// So each one now carries the fact you would have opened it for. Two rules hold this
// together, and both are checked here.
//
// A glance appears only when it has something a dealer would act on. Eight rows that
// always say something are eight rows that say nothing again, and a row reading
// "Nobody waiting" all night is the clutter this went in to remove.
//
// And a glance never says something the panel under it would contradict. It is the
// panel's own sentence with the explanation taken off, so it has to be read off the
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
// at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, makeTable } = require("./load");
const glances = state => Render.panelGlances(state);
const textOf = (state, id) => (glances(state)[id] || {}).text;
// A table part-way through a night: money on it, so the bank check has an opinion.
const night = () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
smallBlind: 5, bigBlind: 10 });
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
return state;
};
describe("the rows that always have something to say", () => {
test("the table's own shape and stakes", () => {
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
});
test("and the stakes drop out rather than reading as free", () => {
// A table set up with no blinds yet is a real state — it is what the panel
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
// than an admission that nobody has said yet.
const state = night();
state.smallBlind = 0;
state.bigBlind = 0;
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
});
test("the chip values, as a range rather than a count of rows", () => {
// "Are the values right for tonight's set" is the question this answers.
// Five colours is the answer to a different one.
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
});
test("with a chip nobody has priced yet left out of the range", () => {
// A blank row in the legend is a chip the dealer hasn't got to, not a
// zero-dollar chip — and letting it in would read the range as starting at $0.
const state = night();
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
});
test("and one chip is a value, not a range of one", () => {
const state = night();
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
});
});
describe("the bank check, which is the one worth a colour", () => {
test("says so when the cash covers the chips", () => {
const state = night();
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Balanced");
assert.equal(tone, "ok");
});
test("and names the gap when it doesn't", () => {
// Chips on the table that nobody paid for. The glance is the alarm and the
// amount; which seat is holding them is a paragraph, and that is the half
// that stays behind the tap.
const state = night();
state.seats[0].buyIns = 100;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $100");
assert.equal(tone, "warn");
});
test("the same way round when the cash is the side that's over", () => {
// One word covers both signs on purpose: the number and the alarm are what a
// dealer acts on, and "short" against "over" is a direction they would have to
// hold in their head to read. The panel says which way in a sentence.
const state = night();
state.seats[0].buyIns = 400;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $200");
assert.equal(tone, "warn");
});
test("and stays quiet over a table nobody has bought into", () => {
// The panel hides its own check here for the same reason. A glance reading
// "Balanced" over an empty table is answering for a count never taken.
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
assert.equal(textOf(state, "glanceStandings"), "");
assert.equal(glances(state).glanceStandings.tone, "");
});
test("and agrees with the sentence the panel would print", () => {
// The two are read off one call apiece and must never disagree — a glance
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
// is worse than no glance at all.
[0, 100, 400].forEach(buyIn => {
const state = night();
state.seats[0].buyIns = buyIn;
const bank = HoldemState.bankReconciliation(state);
const { text, tone } = glances(state).glanceStandings;
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
if (!bank.balanced) {
assert.ok(text.includes(String(Math.abs(bank.difference))),
`${text} doesn't carry the panel's own ${bank.difference}`);
}
});
});
});
describe("the rows that stay quiet until they have news", () => {
test("nobody waiting, no note, no hands played yet", () => {
const state = night();
assert.equal(textOf(state, "glanceWaiting"), "");
assert.equal(textOf(state, "glanceHands"), "");
assert.equal(textOf(state, "glanceNotes"), "");
});
test("and speak up the moment they do", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
assert.equal(textOf(state, "glanceHands"), "1 hand");
});
test("counting one of something as one of it", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
assert.equal(textOf(state, "glanceHands"), "2 hands");
});
test("and a note shows which note it is, not that there is one", () => {
// "Has notes" costs a tap to learn anything, which is the whole failure this
// is fixing. The text is what identifies it; the row's own width ellipsises.
const state = night();
state.notes = "Sam owes Dee $20 from last night";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
});
test("reading past the blank lines a note gets typed under", () => {
// Notes get written as a list — a heading, a blank line, then the thing. The
// first line of the string is regularly not the first line of the note.
const state = night();
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
});
test("and a note of nothing but whitespace is no note", () => {
const state = night();
state.notes = "\n\n \t \n";
assert.equal(textOf(state, "glanceNotes"), "");
});
});
describe("what the row is allowed to be", () => {
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"];
test("every glance the sidebar renders is one this knows about", () => {
// The list drives the DOM writes, so a glance computed and never listed is a
// row that silently keeps its old text for the rest of the night.
const state = night();
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
});
test("and none of them is a paragraph", () => {
// The row is 44px of a 320px column with a label already on it. Anything
// long enough to need the panel's room belongs in the panel.
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
const text = textOf(state, id);
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
});
});
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
// The one glance whose length isn't ours to choose. It must still be a single
// line — a second one would make the row taller, and the row is what holds the
// sidebar to eight equal slabs.
const state = night();
state.notes = "x".repeat(400) + "\nsecond line";
const text = textOf(state, "glanceNotes");
assert.ok(!text.includes("\n"));
assert.equal(text, "x".repeat(400));
});
test("and a state that isn't there yet asks for nothing", () => {
// renderAll runs before the first load has finished on a cold open.
assert.deepEqual(Render.panelGlances(null), {});
assert.deepEqual(Render.panelGlances(undefined), {});
});
});
+127
View File
@@ -0,0 +1,127 @@
// Who can win each pot, said in the fewest names that answer it.
//
// The breakdown used to list every eligible player for every pot. Pot eligibility is
// nested — potCalculator.test.js checks that across 20,000 tables — so the main pot's
// line was every name at the table and each side pot repeated it minus one. The lines
// that were too long to fit were exactly the ones nobody needed spelled out, and the
// short ones at the bottom were the side pots a dealer actually announces by name.
//
// Measured on an iPhone 14 before this: one all-in five-handed already clipped the main
// pot, and twelve-handed the first row wanted 521px of a 254px slot, with no count and
// no title — a dealer reading "Alexandra…" could not tell whether three people or
// eleven were eligible, which is the number the showdown turns on.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// Names are "P<id>" so a test reads as ids and the joining is still exercised.
const nameFor = id => `P${id}`;
const say = (eligible, everyone) => Render.describeEligible(eligible, everyone, nameFor);
describe("the eligible line", () => {
test("says so plainly when the whole table can win it", () => {
// The main pot, every hand. Twelve names to say "nobody is excluded yet" was
// the single longest line in the component and the least informative.
assert.equal(say(["a", "b", "c"], ["a", "b", "c"]), "all 3");
assert.equal(say(["a"], ["a"]), "all 1");
});
test("names who is out when that is the shorter list", () => {
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
assert.equal(say(["b", "c", "d", "e", "f", "g", "h"], table), "7 in, not Pa");
assert.equal(say(["c", "d", "e", "f", "g", "h"], table), "6 in, not Pa and Pb");
assert.equal(say(["d", "e", "f", "g", "h"], table), "5 in, not Pa, Pb and Pc");
});
test("names who is in when that is the shorter list", () => {
// The far side pots, which are the ones a dealer announces by name: "side pot
// is 80 between Ben and Carlos".
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
assert.equal(say(["g", "h"], table), "2 in, Pg and Ph");
assert.equal(say(["h"], table), "1 in, Ph");
assert.equal(say(["f", "g", "h"], table), "3 in, Pf, Pg and Ph");
});
test("and when the two are the same length, says who is in", () => {
// A tie is not a reason to make the dealer do the subtraction: "who can win
// this" is the question, so answer it directly when it costs nothing.
const table = ["a", "b", "c", "d"];
assert.equal(say(["c", "d"], table), "2 in, Pc and Pd");
});
test("leads with the count, because it is the tail that gets cut off", () => {
// .potLayerWho is right-aligned with an ellipsis, so a line too long for a phone
// loses its end. Every form has to begin with the number of hands to ask for.
const table = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
[table, ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], ["k", "l"]]
.forEach(eligible => assert.match(say(eligible, table), /^(all \d+|\d+ in,)/,
`"${say(eligible, table)}" buries the count where a narrow screen can eat it`));
});
test("never spells out more than half the table", () => {
// The point of picking the shorter side. Nested eligibility makes the two lists
// complements, so this is a guarantee rather than a hope.
const table = Array.from({ length: 12 }, (_, i) => `s${i}`);
for (let inCount = 1; inCount <= 12; inCount++) {
const line = say(table.slice(0, inCount), table);
const named = (line.match(/P/g) || []).length;
assert.ok(named <= 6, `${inCount} of 12 named ${named} people: "${line}"`);
}
});
});
// A stub rather than a DOM: the file has no jsdom and does not want one. Enough of an
// element to answer what the row is made of.
const fakeDom = () => {
const make = () => ({
className: "", textContent: "", title: "", children: [],
classList: { add() {} },
appendChild(child) { this.children.push(child); return child; },
});
global.document = { createElement: make };
return () => { delete global.document; };
};
describe("the row it builds", () => {
const pots = [
{ label: "Main pot", amount: 300, eligible: ["a", "b", "c", "d"] },
{ label: "Side pot 1", amount: 150, eligible: ["c", "d"] },
];
const textOf = row => row.children.map(c => c.textContent);
test("carries the full list in a title, for anyone with a pointer", () => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
const who = wrap.children.map(row => row.children[2]);
assert.equal(who[0].title, "Pa, Pb, Pc, Pd");
assert.equal(who[1].title, "Pc, Pd");
// The visible text is the short form; the title is the long one. A tooltip
// is not a mechanism a keyboard reaches, which is why it is the extra and
// not the answer.
assert.equal(who[0].textContent, "all 4");
assert.equal(who[1].textContent, "2 in, Pc and Pd");
} finally { undo(); }
});
test("puts the label, the money and the people in that order", () => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
assert.deepEqual(textOf(wrap.children[0]), ["Main pot", "$300", "all 4"]);
} finally { undo(); }
});
test("and an uncalled bet still reads as money going back", () => {
// Not an eligibility question — nobody is competing for it — so it keeps its
// own wording and gets no title to expand.
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [{ id: "a", amount: 25 }], nameFor);
const row = wrap.children[2];
assert.deepEqual(textOf(row), ["Uncalled", "$25", "back to Pa"]);
assert.equal(row.children[2].title, "");
} finally { undo(); }
});
});
+161
View File
@@ -0,0 +1,161 @@
// The bar's pot list is not allowed to grow with the table.
//
// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table
// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640
// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which
// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay
// whose height depends on the state is a fault whatever it is showing, so the cap is
// checked here rather than left to whoever next adds an all-in to the audit sweep.
//
// The two ends survive because they are the two that are live. Side pots are paid from
// the newest one back down to the main pot, so the top layer is the next thing that
// happens and the main pot is the last, and those are the two a dealer says out loud
// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between
// are the middle of that count-down, wanted at showdown, which is the pot wizard, which
// passes no cap and still lists all of them.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, Money, PotCalculator } = require("./load");
const nameFor = id => `P${id}`;
// The same stub potBreakdown.test.js uses — enough of an element to say what the row
// is made of, and no jsdom in a suite that runs in a second.
const fakeDom = () => {
const make = () => ({
className: "", textContent: "", title: "", children: [],
classList: { add() {} },
appendChild(child) { this.children.push(child); return child; },
});
global.document = { createElement: make };
return () => { delete global.document; };
};
// A table where everyone is all-in for a different amount, which is the shape that
// makes the most pot layers — the worst case and the one that was measured. `count`
// players ladder into `count - 1` layers: two of them share a single undivided pot and
// the breakdown declines to draw at all, which is why nothing below three appears here.
const laddered = count => PotCalculator.layers(
Array.from({ length: count }, (_, i) => ({
id: `s${i}`, contribution: 100 + i * 130, folded: false,
})));
const build = (pots, options) => {
const undo = fakeDom();
try {
const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options);
return wrap.children.map(row => ({
className: row.className,
label: row.children[0].textContent,
amount: row.children[1].textContent,
who: row.children[2].textContent,
title: row.children[2].title,
}));
} finally { undo(); }
};
describe("the bar's copy of the pot list", () => {
test("stops growing once there are more pots than it has room for", () => {
// The property that matters: rows are a function of the cap, not of the table.
// Twelve-handed and six-handed have to render the same number of lines.
for (let players = 3; players <= 12; players++) {
const rows = build(laddered(players), { maxLayers: 4 });
assert.ok(rows.length <= 4,
`${players} all-in drew ${rows.length} rows; the bar is sized for 4`);
}
});
test("and the number of rows never depends on how many people are all-in", () => {
const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length);
assert.deepEqual(wide, [3, 3, 3, 3, 3],
"past the cap every table should draw main, the elision and the top pot");
});
test("keeps the two pots a dealer actually announces", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid");
assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label,
"the newest side pot is the first one paid");
});
test("and leaves them in the order the pots were made", () => {
const rows = build(laddered(12), { maxLayers: 4 });
assert.match(rows[1].className, /potLayerMore/,
"the elision belongs where the gap is, not appended after the list");
});
});
describe("the money in the rows that were elided", () => {
test("is still on the screen, added up", () => {
// The one thing a summary row must not do is lose chips. Whatever is drawn has
// to account for every pot, or a dealer counting the announcement against the
// chips in the middle finds a shortfall the tool caused.
for (let players = 3; players <= 12; players++) {
const pots = laddered(players);
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
const drawn = build(pots, { maxLayers: 4 })
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
assert.equal(drawn, total,
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
}
});
test("and says how many it stands for", () => {
const rows = build(laddered(12), { maxLayers: 4 });
const pots = laddered(12);
assert.equal(rows[1].label, `${pots.length - 2} more side pots`);
});
test("with the list itself in a title, for anyone with a pointer", () => {
const pots = laddered(12);
const rows = build(pots, { maxLayers: 4 });
pots.slice(1, -1).forEach(pot => {
assert.ok(rows[1].title.includes(pot.label),
`${pot.label} vanished without trace`);
assert.ok(rows[1].title.includes(Money.format(pot.amount)),
`${pot.label}'s amount vanished without trace`);
});
});
test("and points at where the whole list is", () => {
// The title is a pointer affordance, so it cannot be the answer on a phone. The
// visible text has to say where the rest lives.
assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i);
});
});
describe("when eliding would not buy anything", () => {
test("a list that fits is left alone", () => {
for (let players = 3; players <= 5; players++) {
const pots = laddered(players);
assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes");
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows.length, pots.length);
assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label));
}
});
test("and a single middle layer is not replaced by a line saying there is one", () => {
// At exactly the cap the middle is one row, and swapping one row for one row is
// pure indirection: the dealer loses an amount and gains a sentence.
const pots = laddered(5);
assert.equal(pots.length, 4, "five all-in ladder into exactly the cap");
const rows = build(pots, { maxLayers: 4 });
assert.equal(rows.length, 4);
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
});
});
describe("the wizard, which has room", () => {
test("still gets every layer", () => {
// Same builder, no cap — this is the announcement read out before anybody shows
// a hand, and it is in a dialog that scrolls. Capping it there would hide the
// pots at the moment they are being paid.
const pots = laddered(12);
const rows = build(pots, undefined);
assert.equal(rows.length, pots.length);
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
});
});
+60
View File
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
});
});
// The pots come out of layers() in one order and with one relationship between them,
// and the action bar's wording now depends on both. If eligibility were not nested,
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
// pot who was never eligible for the main one, and naming the complement would hide
// them. It is a property of how the layers are built rather than an accident of the
// examples above, so it is checked as one.
describe("pot eligibility is nested", () => {
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
test("every side pot's players are a strict subset of the pot below it", () => {
const random = rng(20260905);
let multiPotTables = 0;
let pairs = 0;
for (let table = 0; table < 20000; table++) {
const n = 2 + Math.floor(random() * 11);
const players = Array.from({ length: n }, (_, i) => p(
`p${i}`,
// A sixth of the table never puts anything in, which is a table mid-deal.
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
random() < 0.2));
const layers = PotCalculator.layers(players);
if (layers.length > 1) multiPotTables++;
for (let i = 1; i < layers.length; i++) {
const below = layers[i - 1].eligible;
const belowSet = new Set(below);
const here = layers[i].eligible;
pairs++;
assert.ok(here.every(id => belowSet.has(id)),
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
JSON.stringify(players));
assert.ok(here.length < below.length,
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
`layers with the same eligibility are supposed to be merged into one pot\n` +
JSON.stringify(players));
}
}
// Guards the generator rather than the code: a shape that stopped producing side
// pots would make every assertion above vacuous and the test would still pass.
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
});
test("so the main pot is everyone still live, and it is the widest", () => {
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
layers.slice(1).forEach(layer =>
assert.ok(layer.eligible.length < layers[0].eligible.length));
});
});
+77 -5
View File
@@ -40,12 +40,21 @@ const OVERLAYS = [
// sitting down more than on anything a viewport can tell you. A full table is where the
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
// of fifty-four tables had one player's name or stack painted under another.
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
const TABLES = [
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600]], false],
["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]],
["packed", 12, PACKED],
// The same twelve, every one of them all-in for a different amount. This is the
// shape that makes a pot layer per player, and the sweep had never reached it:
// every table here was driven with Call and Check, so the side-pot breakdown — the
// one thing on the bar whose height depends on the table — was empty at all 294
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
// left 96px of felt for twelve seats.
["all in", 12, PACKED, false, 12],
// The half-full table again, with the board turned on and five cards on it. The
// board is furniture in the middle of the felt that nothing else was ever measured
// against — and where the middle cannot hold it, it moves to a strip above the
@@ -116,7 +125,7 @@ async function main() {
for (const [width, height, rawLabel] of VIEWPORTS) {
await page.setViewportSize({ width, height });
for (const [shape, seats, players, board] of TABLES) {
for (const [shape, seats, players, board, shoves] of TABLES) {
const label = `${rawLabel} · ${shape} · ${hand}`;
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable, [seats, players, board]);
@@ -133,7 +142,36 @@ async function main() {
if (deal) deal.click();
});
await page.waitForTimeout(220);
for (let i = 0; i < 3; i++) {
// Everyone's whole stack in, which is what stacks up the side pots.
// There is no one-tap shove: a player who can't cover the bet gets
// "Call all-in", and anyone else has to open the raise pad, take its
// All-in preset and confirm.
for (let i = 0; i < (shoves || 0); i++) {
const step = await page.evaluate(() => {
const find = re => [...document.querySelectorAll("#actionBar button")]
.find(b => !b.disabled && re.test(b.textContent.trim()));
const short = find(/^Call all-in/);
if (short) { short.click(); return "done"; }
const open = find(/^Raise|^Bet/);
if (!open) return null;
open.click();
return "pad";
});
if (!step) break;
await page.waitForTimeout(120);
if (step === "pad") {
await page.evaluate(() => {
const find = re => [...document.querySelectorAll("#actionBar button")]
.find(b => !b.disabled && re.test(b.textContent.trim()));
const max = find(/^All-in/);
if (max) max.click();
const go = find(/^Raise to|^Bet/);
if (go) go.click();
});
await page.waitForTimeout(120);
}
}
for (let i = 0; i < (shoves ? 0 : 3); i++) {
const acted = await page.evaluate(() => {
const btn = [...document.querySelectorAll("#actionBar button")]
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
@@ -166,6 +204,40 @@ async function main() {
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
// The raise pad, which is part of the bar rather than an overlay and so
// was never once measured by any of this. It is the tallest thing the
// bar ever holds and the only place in the tool a dealer types an
// amount, and it had a 36px-tall field on every phone in the list: the
// `hover: none` rule that sizes fields for a thumb names the settings
// panels and the seat form, and the one field on the thumb surface was
// not in the list. Nothing here could see that, because nothing here
// ever opened it.
if (await page.evaluate(() => {
const btn = [...document.querySelectorAll("#actionBar button")]
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
if (!btn) return false;
btn.click();
return true;
})) {
await page.waitForTimeout(200);
checks++;
const pad = await page.evaluate(() => LayoutAudit.runAll({
occlusionSelector: "#actionBar button, #actionBar input",
}));
if (!pad.clean) {
failures.push({
where: `raise pad · ${label} ${width}x${height}`,
offenders: pad.failed.flatMap(f => f.offenders),
});
}
await page.evaluate(() => {
const cancel = [...document.querySelectorAll("#actionBar button")]
.find(b => /^Cancel/.test(b.textContent.trim()));
if (cancel) cancel.click();
});
await page.waitForTimeout(150);
}
// The overlays don't change with the table's shape, so they are only
// opened over the half-full one — twice the viewports is enough time
// spent already.
+53 -1
View File
@@ -165,10 +165,62 @@ describe("what the stylesheet has to keep holding up", () => {
});
});
describe("the blind badge counts", () => {
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
// off the puck and always points away from the table — `top: -9px` on the upper arc,
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
// them back to back and it costs nothing. That much is true.
//
// What was also claimed is that two seats on the *same* arc "wear them at the same
// height, offset by exactly as much as the pucks are". True of a straight rail,
// false of a ring: neighbours on a curve sit at different heights, so the badge
// pointing away from the table on one of them points at the corner of the other.
// Measured on the running page, three tables in the sweep had a player's stack
// painted under the next player's SB or BB — and the badge outranks everything else
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
test("a seat needs room for the badge it might be wearing", () => {
const puck = { width: 64, height: 40 };
assert.deepEqual(Render.seatFootprint(puck),
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
});
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
// It sits inside the puck's own width — see "all three sit in the same place"
// above, which holds the stylesheet to it.
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
});
test("the number here is the number the stylesheet draws", () => {
// Two files have to agree about one distance: holdem.css hangs the badge off the
// puck by it, render.js charges every chair in the ring for it. They are wired
// together by nothing but this test. Drift either way is silent — too small and
// badges land on neighbours again, too large and every puck on a nine-handed
// phone gives up a size step for clearance nothing needs.
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
assert.ok(top && bottom, "the badge should hang off both arcs");
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
"the same distance at both ends, or one arc is charged for the other's badge");
});
test("and it is counted once, not once per seat", () => {
// seatsCollide is given one box and compares centres against it, so the
// separation it asks for is one puck plus one badge. That is exactly right for
// two seats on the same arc each wearing one, and generous for the far commoner
// case where only one of the two is in the blinds at all. Counting it on both
// sides is what made a landscape table unsatisfiable the first time this was
// tried, back when that felt was 87px tall.
const puck = { width: 64, height: 40 };
const grew = Render.seatFootprint(puck).height - puck.height;
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
});
});
describe("with no browser to measure", () => {
test("nothing here throws", () => {
assert.equal(Render.measuredSeatBox(), null);
assert.equal(Render.seatFootprint(null), null);
assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 });
});
});
+125
View File
@@ -0,0 +1,125 @@
// What the ring is allowed to be sized from.
//
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
// shows what a player has put in. Both used to hold their space anyway, and the pill,
// which has a background and a pill radius and side padding, painted an 8x11px green
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
// table, a coloured mark on almost every seat standing for nothing.
//
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
// the seats overlapped by 1556px² and now overlap by 87px².
//
// The collapse is only safe if the layout re-measures when one of those rows fills,
// because resolveSeatLayout caches its ten forced layouts against this signature. That
// is what is checked here: the cache key has to move when the puck's shape can, and
// stay still when it cannot — a signature that changed on every action would throw the
// cache away on every tap, which is the cost it exists to avoid.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, makeTable } = require("./load");
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
const signatureOf = state => {
const before = Render.state;
Render.state = state;
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
};
const table = () => makeTable({ seatCount: 6, stack: 200 });
describe("the signature the ring layout is cached against", () => {
test("moves when the first chips go in", () => {
// Before the blinds every pill is empty and the pucks are short; after them two
// seats are ~15px taller. A ring still sized from the first measurement is sized
// for a puck that no longer exists.
const state = table();
const empty = signatureOf(state);
state.seats[1].committed = 10;
assert.notEqual(signatureOf(state), empty,
"the ring would still be sized for pucks with no amount on them");
});
test("and when a hole card is entered", () => {
const state = table();
const none = signatureOf(state);
state.seats[0].cards = ["AS", "KC"];
assert.notEqual(signatureOf(state), none);
});
test("but not again for the second player to put chips in", () => {
// The whole point of a boolean over the table. The ring is sized from the
// tallest puck on it, so once anybody has an amount the answer cannot change —
// and re-measuring here would mean ten forced layouts on every single action.
const state = table();
state.seats[1].committed = 10;
const first = signatureOf(state);
state.seats[2].committed = 10;
assert.equal(signatureOf(state), first);
state.seats[3].committed = 250;
assert.equal(signatureOf(state), first);
});
test("nor when an amount merely changes size", () => {
const state = table();
state.seats[1].committed = 10;
const small = signatureOf(state);
state.seats[1].committed = 999999;
assert.equal(signatureOf(state), small,
"a wider number is the amount fitter's problem, not the ring's");
});
test("and it still moves for the things it always moved for", () => {
// Renaming can wrap a name onto a second line, and the button's chip floats
// into the name's first line, so both change how tall a puck measures.
const state = table();
const base = signatureOf(state);
state.seats[0].name = "Bartholomew Fairweather";
const renamed = signatureOf(state);
assert.notEqual(renamed, base);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.notEqual(signatureOf(state), renamed);
});
test("and two tables that differ only in where a name is split stay different", () => {
// The names are joined on a control character no name can contain. Joined on
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
// tables would be laid out with the other's measurements — different names, so
// different wrapping, so different puck heights.
//
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
// never collide: the capital letters land in different places, so it passed with
// the separator taken out and proved nothing at all. Lower case is what makes it
// a real test.
const a = table();
const b = table();
a.seats[0].name = "ann"; a.seats[1].name = "abel";
b.seats[0].name = "anna"; b.seats[1].name = "bel";
assert.notEqual(signatureOf(a), signatureOf(b));
});
});
describe("a table nobody has sat down at", () => {
test("has no amounts and no cards on it", () => {
// The empty-table case is the one the collapse is most visible on, and it is
// also the one where a stray default could quietly keep both rows open forever.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
assert.match(signatureOf(state), /\|$/,
"an untouched table should carry neither flag");
});
test("but a table with an ante on it does have an amount", () => {
// computeContribution answers for a seat that has never been given a stack —
// the table's base pot stands in for it — so a table dealt with "everyone antes
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.basePotAmount = 5;
state.seats.forEach(p => { p.inHand = true; });
assert.match(signatureOf(state), /a$/);
});
});
+129
View File
@@ -0,0 +1,129 @@
// How wide a seat puck is allowed to get, and on which screens.
//
// The felt grows with the window and the pucks did not: 96px was the only ceiling there
// was, so nine-handed the seats covered 19% of the felt on a phone, 11% on a 1280px
// desktop and 8% at 1440px and beyond — with the stack figure a dealer reads sitting at
// 10px on a 2560px monitor, the same 10px as on a 360px phone.
//
// Two things stop the fix from costing more than it buys, and both are checked here.
//
// Every candidate width is a forced layout, and raising the ceiling for everybody took
// the layout pass from 24ms to 73ms on a twelve-handed desktop — so a screen that cannot
// use the extra widths must not measure them. And a 144px puck on an iPhone's 278px ring
// is half the width of the table: the first version of this let phones through, and
// six-handed all six seats sat over the rail by 15px against 5px before, reading as a
// stack of cards rather than a table with people around it.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// Ring boxes read off the running page, not invented. #seatsLayer, nine-handed, at the
// viewports the sweep visits.
const RINGS = {
"Galaxy S8": { width: 233, height: 282 },
"iPhone 14": { width: 263, height: 470 },
"Pro Max": { width: 303, height: 535 },
"phone sideways": { width: 565, height: 257 },
iPad: { width: 689, height: 689 },
desktop: { width: 857, height: 617 },
"desktop 1440": { width: 992, height: 714 },
};
const SMALL = ["Galaxy S8", "iPhone 14", "Pro Max", "phone sideways"];
const LARGE = ["iPad", "desktop", "desktop 1440"];
describe("which felts may have wider pucks at all", () => {
test("no phone does, held either way up", () => {
// The guarantee the phones are owed: they measure the widths they always
// measured and settle on the puck they always settled on.
SMALL.forEach(name => {
assert.ok(Render.roomyWidthCeiling(RINGS[name]) <= Render.SEAT_WIDTH_MAX,
`${name} would be allowed a ${Render.roomyWidthCeiling(RINGS[name])}px puck`);
[2, 4, 6, 9, 12].forEach(count => assert.equal(
Render.roomForWiderPucks(count, RINGS[name]), false,
`${name} ${count}-handed`));
});
});
test("and every tablet and desktop does", () => {
LARGE.forEach(name => {
assert.ok(Render.roomyWidthCeiling(RINGS[name]) > Render.SEAT_WIDTH_MAX, name);
});
});
test("with a comfortable margin either side of the line", () => {
// The rule is a fifth of the shorter span, and the point of five is that it is
// not near either group. If a change to the felt ever moves these together,
// this says so before the phones quietly start paying for it.
const biggestSmall = Math.max(...SMALL.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
const smallestLarge = Math.min(...LARGE.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
assert.ok(smallestLarge > biggestSmall * 1.8,
`${biggestSmall}px and ${smallestLarge}px are too close for one divisor to separate`);
// Every divisor that keeps the phones out is at least the widest phone span over
// the ordinary ceiling; every one that lets the tablets in is below the narrowest
// large span over the same. 303/96 = 3.2 and 617/96 = 6.4, and five is between.
const floor = biggestSmall / Render.SEAT_WIDTH_MAX;
const cap = smallestLarge / Render.SEAT_WIDTH_MAX;
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO >= floor,
`${Render.SEAT_ROOMY_SPAN_RATIO} lets a phone through; it has to be at least ${floor.toFixed(2)}`);
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO < cap,
`${Render.SEAT_ROOMY_SPAN_RATIO} shuts the tablets out; it has to be under ${cap.toFixed(2)}`);
});
test("and a felt with no size at all is not one of them", () => {
assert.equal(Render.roomForWiderPucks(9, null), false);
assert.equal(Render.roomForWiderPucks(9, { width: 0, height: 0 }), false);
});
});
describe("the widths a felt actually measures", () => {
const widthsFor = (name, count) => Render.seatWidthCandidates(count, RINGS[name]);
test("a phone measures exactly the ten it measured before", () => {
const expected = [];
for (let w = Render.SEAT_WIDTH_MAX; w > Render.SEAT_WIDTH_MIN; w -= Render.SEAT_WIDTH_STEP) {
expected.push(w);
}
expected.push(Render.SEAT_WIDTH_MIN);
SMALL.forEach(name => [6, 9, 12].forEach(count =>
assert.deepEqual(widthsFor(name, count), expected, `${name} ${count}-handed`)));
});
test("a desktop measures a handful more, and no more than a handful", () => {
const extra = widthsFor("desktop 1440", 9).length - widthsFor("Galaxy S8", 9).length;
assert.ok(extra > 0, "the wider widths never got offered");
assert.ok(extra <= 6, `${extra} extra forced layouts is more than this is worth`);
});
test("never wider than the felt's own ceiling", () => {
Object.keys(RINGS).forEach(name => [2, 6, 9, 12].forEach(count => {
const widest = Math.max(...widthsFor(name, count));
assert.ok(widest <= Math.max(Render.SEAT_WIDTH_MAX, Render.roomyWidthCeiling(RINGS[name])),
`${name} ${count}-handed offered ${widest}px`);
assert.ok(widest <= Render.SEAT_WIDTH_ROOMY_MAX);
}));
});
test("always widest first, always down to the floor", () => {
// The search takes the first width that fits, so the order is the whole
// algorithm — and the floor has to be on the list or a crowded table has
// nothing left to fall back to.
Object.keys(RINGS).forEach(name => [2, 9, 12].forEach(count => {
const widths = widthsFor(name, count);
widths.forEach((w, i) => {
if (i === 0) return;
assert.ok(w < widths[i - 1], `${name}: ${widths.join(", ")} is not descending`);
});
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
}));
});
test("and a crowded table on a big felt is told no like any other", () => {
// Room on the felt is necessary, not sufficient: the seats still have to clear
// each other. Enough of them on any ring and the wider widths stop being
// offered, which is the arithmetic doing the refusing rather than the
// measurement having to.
const crowded = [24, 30, 40].map(count => Render.roomForWiderPucks(count, RINGS.desktop));
assert.deepEqual(crowded, [false, false, false]);
});
});
+124
View File
@@ -0,0 +1,124 @@
// What the sidebar is a list of.
//
// The panels were Table Setup, Session, Chip Legend, Keyboard and Table Notes, and
// "Session" was a container word rather than a name. It held the standings, a waiting
// list, the hand log and the save/load/reset buttons — a topic, a task, a log and three
// utilities under one label, which is the "don't mix apples and oranges" failure: a
// navigation layer with more than one logic in it. Measured at 1206px and 103 visible
// elements, against 555 and 29 for the next biggest, and "The table itself" sat 1052px
// down inside it, so reaching Reset table meant opening a panel called Session and
// scrolling past the money and the log.
//
// This is a markup test because that is where the fault lived. Nothing was broken —
// every control worked and every id resolved — so nothing failed. What was wrong was
// which panel a thing was in and what that panel was called.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// A sidebar panel never contains another one — Table Setup's sub-panels carry a
// different class — so splitting on the opening tag is enough to say which panel any
// piece of markup is in, without parsing the document.
const panels = () => PAGE
.split(/<details class="sidebarPanel"/)
.slice(1)
.map(chunk => {
const name = chunk.match(/<summary>\s*(?:<h2>)?([^<]+)/);
return { name: name ? name[1].trim() : "(no summary)", markup: chunk };
});
const panelHolding = id => {
const found = panels().filter(p => p.markup.includes(`id="${id}"`));
assert.equal(found.length, 1, `${id} is in ${found.length} panels, expected exactly 1`);
return found[0].name;
};
// ---------------------------------------------------------------------------
describe("the sidebar's names", () => {
test("say what is inside them", () => {
// Every one of these is a thing a dealer goes looking for. None of them is a
// word that could hold anything — which is the test "Session" fails.
assert.deepEqual(panels().map(p => p.name), [
"Table Setup",
"Standings",
"Waiting list",
"Hands",
"Chip Legend",
"Keyboard",
"Table Notes",
"Save &amp; reset",
]);
});
test("and each one is a heading, not only a button", () => {
// A <summary> is announced as a button. The sidebar's names are the whole of its
// navigation, so without a heading inside, none of them is reachable by anyone
// moving through the page by heading — and splitting Session removed the four
// subheads that used to be the only headings down here.
panels().forEach(p => assert.match(p.markup, /<summary><h2>/,
`the ${p.name} panel's name is not a heading`));
});
});
describe("where things live", () => {
test("the money is together, and the warning about losing it is with it", () => {
assert.equal(panelHolding("standingsList"), "Standings");
assert.equal(panelHolding("settleUp"), "Standings");
assert.equal(panelHolding("bankReconcile"), "Standings");
// renderStorageWarning() says this "sits at the top of the panel a dealer opens
// to check the money". That panel is now called Standings.
assert.equal(panelHolding("storageWarning"), "Standings");
});
test("the queue for a seat is its own panel", () => {
assert.equal(panelHolding("waitingList"), "Waiting list");
assert.equal(panelHolding("waitingListForm"), "Waiting list");
});
test("the log and the two ways out of it are together", () => {
assert.equal(panelHolding("handHistoryList"), "Hands");
assert.equal(panelHolding("handHistoryFilter"), "Hands");
assert.equal(panelHolding("exportHistoryBtn"), "Hands");
assert.equal(panelHolding("clearHistoryBtn"), "Hands");
});
test("and the file is last, with the danger on the label", () => {
assert.equal(panelHolding("saveTableBtn"), "Save &amp; reset");
assert.equal(panelHolding("loadTableBtn"), "Save &amp; reset");
assert.equal(panelHolding("resetTableBtn"), "Save &amp; reset");
assert.equal(panels()[panels().length - 1].name, "Save &amp; reset",
"the panel that ends the night belongs at the end, away from the working ones");
});
});
describe("no panel goes back to being a grab-bag", () => {
// Not a size limit — Table Setup is legitimately long and Chip Legend legitimately
// repeats a row per chip. What this counts is *subjects*: a panel that needs its own
// headings to say what its parts are is a panel holding more than one thing, which
// is what Session was and how it got that way.
test("at most one subject needs a heading inside a panel", () => {
panels().forEach(p => {
const subheads = [...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
.map(m => m[1].trim());
assert.ok(subheads.length <= 1,
`${p.name} has ${subheads.length} sections inside it (${subheads.join(", ")}) — ` +
`if a panel needs headings to list its parts, its name is not doing the work`);
});
});
test("and the ones that remain are a detail of their panel, not a second subject", () => {
// Settle up is the standings once the night ends; Stacks in chips is the chip
// legend counted out. Both are the same subject at a different moment, which is
// the case a subhead is for.
const kept = panels().flatMap(p =>
[...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
.map(m => `${p.name}: ${m[1].trim()}`));
assert.deepEqual(kept, ["Standings: Settle up", "Chip Legend: Stacks in chips"]);
});
});
+500
View File
@@ -0,0 +1,500 @@
// A phone held sideways, and the three pieces of arithmetic that make the table fit on
// one.
//
// The layout audit is what proved this was broken and what proves it is fixed — it
// measures the drawn page, which is the only place a layout is really true. What is
// here is the part underneath that can be wrong on its own: which shape the bar is in,
// how much of the bottom that leaves spoken for, how a name is shortened when it will
// not fit, and how the ring is pulled back in when a puck lands past the rail.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { ActionBar, Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// ---------------------------------------------------------------------------
describe("which shape the bar is in", () => {
// The layout is chosen by a media query — orientation, height and width together.
// The one thing that must not happen is a second copy of that query in JavaScript,
// because the first thing anybody changes is the breakpoint, in one of the two
// places. So the stylesheet declares which layout it picked and actionBar.js reads
// the answer back.
const withComputed = (value, run) => {
const host = { id: "actionBar" };
global.getComputedStyle = el => ({
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
});
try { return run(host); } finally { delete global.getComputedStyle; }
};
test("the stylesheet says, and this reads", () => {
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
});
test("anything it does not recognise is the bar it has always been", () => {
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
});
test("and with no browser at all, likewise", () => {
assert.equal(ActionBar.isRail(null), false);
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
"no getComputedStyle to ask — this runs in the unit suite too");
});
test("the stylesheet declares both halves of it", () => {
// If either of these goes, isRail() quietly answers "bar" forever and the felt
// is measured against a bar that is not underneath it.
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
"the default, on the base rule");
assert.match(CSS, /--actionBarLayout:\s*rail/,
"and the rail, inside the landscape query");
});
test("the rail is scoped to a phone, not to any short window", () => {
// Above 900px the sidebar is already a right-hand column and a rail would land
// on top of it.
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
assert.ok(query, "the rail lives in a landscape media query");
assert.match(query[0], /max-width:\s*900px/);
assert.match(query[0], /max-height:\s*560px/);
});
});
// ---------------------------------------------------------------------------
describe("the bar lines up with the table", () => {
// On a desktop the bar was centred on the window and the table on its own column,
// and the sidebar takes 320px out of the right of that — so the two things a dealer
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
// between, because the offset is half the sidebar and the sidebar is a constant.
//
// The rule that fixes it is four numbers that have to keep agreeing with four other
// numbers, none of them anywhere near each other in the file. That is what this
// block is for: the arithmetic is checked on the running page by measurement (bar
// centre against table-column centre, which came out 0px off at every width), and
// the numbers it depends on are checked here so they cannot drift apart afterwards.
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
// Below 901px the sidebar is not a column at all — it is stacked under the table
// — so there is nothing to lean away from and the bar is centred as it was.
assert.ok(rule, "no min-width: 901px rule for #actionBar");
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
"the one-column layout should start exactly where this rule stops");
});
test("it leans away from the sidebar by the sidebar's own width", () => {
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
});
test("and the sidebar's width is one number, not two", () => {
// The grid draws the column and the bar leans away from it. Written out twice,
// the day somebody widens the sidebar is the day the bar goes crooked and no
// test says so.
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
"the grid should draw the column from the token");
});
test("no 100vw anywhere in it", () => {
// The bar's own centring comment warns about this: on a page with a scrollbar
// 100vw is wider than the viewport, and the overflow drags the table sideways.
// The offsets are exact without it — see the derivation in the stylesheet.
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
});
});
// ---------------------------------------------------------------------------
describe("the button seat's name", () => {
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out
// from under it with `padding-right` takes the chip's width off *every* line of the
// name, and the chip is on one of them: measured at 1280x900, the name box is 78px,
// "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56
// and broke as "Christop / her". The button moves every hand, so that is a player
// whose name reads properly for four hands and comes apart on the fifth.
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/);
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
test("a ring with room for two lines indents only the line beside the chip", () => {
assert.ok(roomy && float, "the roomy regime should use a float");
assert.match(float[1], /float:\s*right/);
assert.match(roomy[1], /padding-right:\s*0/);
});
test("the float is exactly one line tall", () => {
// Tall enough to cover the chip's reach into the name, and no taller: a float
// that spills into the second line indents that one too, which puts the whole
// problem back.
assert.match(float[1], /height:\s*1\.2em/);
});
test("break-word stands down where the float is doing the work", () => {
// It is what stops the float working — it breaks a word to fit the short line
// rather than letting it move past the float to the long one.
assert.match(roomy[1], /overflow-wrap:\s*normal/);
});
test("a crowded ring keeps the padding, because it has only one line", () => {
// `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
// nowhere to move to — the only way to keep it off the chip is to shorten it.
// Trying the float on both cost a whole sweep to find out: the name dropped onto
// a second line that the chip's lower half was still sitting on, and the audit
// called it at every viewport in the list.
assert.ok(base, "the base rule should still reserve the chip's width");
assert.match(base[1], /padding-right:\s*22px/);
assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
});
test("the float is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const floatW = float[1].match(/width:\s*(\d+)px/);
assert.ok(chip && inset && floatW);
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
"the float should cover the chip and the gap it is inset by");
});
});
// ---------------------------------------------------------------------------
describe("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
// the table hint and the two links, because the bar is off to the side.
const fakeSection = (wrapBottom, sectionBottom) => {
const wrap = {
getBoundingClientRect: () => ({ bottom: wrapBottom }),
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
};
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
};
test.afterEach(() => { delete global.document; });
test("it is the gap under the felt, links and all", () => {
fakeSection(300, 342);
assert.equal(ActionBar.roomBelowFelt(), 42);
});
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
// as "how tall is everything under the felt" off the section's height it would
// move every time the felt did, and settle nowhere.
fakeSection(300, 342);
const first = ActionBar.roomBelowFelt();
fakeSection(500, 542); // the felt grew by 200; what is under it did not
assert.equal(ActionBar.roomBelowFelt(), first);
});
test("nothing under the felt is nothing to reserve", () => {
fakeSection(300, 300);
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("and a section that measures shorter than its own child is not negative room", () => {
fakeSection(300, 288);
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("with no table on the page it asks for nothing", () => {
global.document = { getElementById: () => null };
assert.equal(ActionBar.roomBelowFelt(), 0);
});
test("the felt gets the height the bar used to take", () => {
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
// and the felt got 85. Beside the table instead, the same window gives the felt
// everything but the header and the links.
const asABar = ActionBar.feltRoomFor(360, 205, 70);
const asARail = ActionBar.feltRoomFor(360, 30, 70);
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
});
});
// ---------------------------------------------------------------------------
describe("a name that will not fit", () => {
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
// a nine-handed phone, and what the ellipsis produced for the four names on the test
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
// away something the dealer does not need: he is Mike, and the rest of it is how
// this table tells him from the other Mike.
//
// The label element is faked here rather than measured. What is under test is the
// ladder — which words go, in what order, and when it stops — and the browser's part
// is one boolean per pass.
const label = (text, fits) => {
const el = {
textContent: text,
title: "",
// The browser's answer, standing in for a real one: this name fits if
// `fits` says the version currently written to it does.
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
get clientWidth() { return 10; },
get scrollHeight() { return 10; },
get clientHeight() { return 10; },
querySelector: () => el,
};
return el;
};
const layer = (labels) => ({
children: labels,
childElementCount: labels.length,
});
const seatsNamed = (...names) => {
Render.state = { seats: names.map(name => ({ name })) };
};
test.afterEach(() => { Render.state = null; });
test("trailing words go until it fits", () => {
const el = label("Christopher John Robinson", text => text === "Christopher John");
seatsNamed("Christopher John Robinson");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher John");
});
test("and it will go all the way to the first word", () => {
const el = label("Christopher John Robinson", text => text === "Christopher");
seatsNamed("Christopher John Robinson");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher");
});
test("a name never ends on a word a name cannot end on", () => {
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
// shorter name — it is a name that looks cut off, and it reads worse than the
// ellipsis this was meant to replace. He is Mike.
const el = label("Mike from work", () => false);
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike");
});
test("and it takes a run of them in one go", () => {
const el = label("Al with the hat", () => false);
seatsNamed("Al with the hat");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Al");
});
test("but a joining word inside a name is nobody's business", () => {
// The list is closed and small on purpose: it is not trying to understand names,
// only the handful of English joining words people label each other with at a
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
// a name that keeps a word it could have dropped is merely long.
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
seatsNamed("Katarzyna Nowak Kowalska");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Katarzyna Nowak");
});
test("dropping never goes below the first word", () => {
// Every word after the first being a joining word is not a real name, but it is
// typeable, and it must not come out empty.
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
});
test("a single word is left to the ellipsis it always had", () => {
// There is no honest way to shorten "Christopher" that a dealer glancing across
// a table would read faster than "Christo…".
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
});
test("a name that fits is not touched", () => {
const el = label("Mike from work", () => true);
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike from work");
});
test("the whole name is on the tooltip whether it was shortened or not", () => {
// A label clamped to two lines was already hiding some of it before any of this.
const shortened = label("Mike from work", text => text === "Mike");
const untouched = label("Bob", () => true);
seatsNamed("Mike from work", "Bob");
Render.fitSeatNames(layer([shortened, untouched]));
assert.equal(shortened.title, "Mike from work");
assert.equal(untouched.title, "Bob");
});
test("every seat is written to before any of them is measured", () => {
// Three passes over all the seats, not a loop per seat: each pass writes to
// every puck and then reads back from every puck, so the browser lays the felt
// out three times whatever the table's size. Interleaved, nine seats would cost
// nine layouts, and this runs on every render.
const reads = [];
const watched = (text, fits) => {
const el = label(text, fits);
Object.defineProperty(el, "scrollWidth", {
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
});
return el;
};
const a = watched("Christopher John Robinson", t => t === "Christopher");
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
Render.fitSeatNames(layer([a, b]));
// Both names are read at each length before either is written down again.
assert.deepEqual(reads.slice(0, 2),
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
assert.equal(a.textContent, "Christopher");
assert.equal(b.textContent, "Katarzyna");
});
test("an empty chair has no name to shorten", () => {
const el = label("", () => false);
seatsNamed("");
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
});
test("and with no layer there is nothing to do", () => {
assert.doesNotThrow(() => Render.fitSeatNames(null));
});
});
// ---------------------------------------------------------------------------
describe("pulling the ring back onto the table", () => {
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
// the rail, and it is right about that — but it is right about a puck it measured
// before the ring moved, before the width search settled, and before a name was
// shortened or wrapped to a second line. Each of those changes a puck's height by up
// to 15px after the arithmetic is done.
//
// Measured on the running page, four of the thirteen viewports finished with the top
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
// the ring is settled the way the audit judges it, by looking at where the pucks
// actually landed.
//
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
// percentage into style.left/top, which this reads back to work out where the puck
// would be — the same relationship the browser has, without a browser.
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
const seat = (puckW, puckH, box) => ({
style: { left: "", top: "" },
classList: { toggle() {} },
getBoundingClientRect() {
// Whatever percentage was last written, resolved against the ring box.
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
return {
left: cx - puckW / 2, right: cx + puckW / 2,
top: cy - puckH / 2, bottom: cy + puckH / 2,
width: puckW, height: puckH,
};
},
});
// The ring box the percentages resolve against, deliberately the same as the felt so
// the arithmetic in the test stays readable.
const ringBox = { ...felt };
const setUp = (count, puckW, puckH) => {
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
global.document = { getElementById: id => (id === "pokerTable"
? { getBoundingClientRect: () => felt }
: { getBoundingClientRect: () => ringBox }) };
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
return { children: seats, childElementCount: seats.length };
};
const place = (layer, radii) => {
for (let i = 0; i < layer.childElementCount; i++) {
Render.placeSeat(layer.children[i], i, 1, radii);
}
};
const worstOverhang = layer => {
let over = 0;
for (let i = 0; i < layer.childElementCount; i++) {
const b = layer.children[i].getBoundingClientRect();
over = Math.max(over, felt.left - b.left, b.right - felt.right,
felt.top - b.top, b.bottom - felt.bottom);
}
return over;
};
test.afterEach(() => { Render.state = null; delete global.document; });
test("a ring already inside the rail is left exactly where it is", () => {
const layer = setUp(6, 60, 50);
const radii = { rx: 30, ry: 30 };
place(layer, radii);
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
});
test("a puck past the rail brings the whole ring in", () => {
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
// another 30, against a felt whose half-height is 200. Six pixels over, at the
// top and the bottom and both sides — which is the size the running page was
// actually wrong by.
const layer = setUp(4, 60, 60);
const radii = { rx: 44, ry: 44 };
place(layer, radii);
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
const settled = Render.settleSeatsOnTable(layer, 1, radii);
assert.ok(settled.ry < radii.ry, "the ring came in");
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
`still ${worstOverhang(layer).toFixed(1)}px over`);
});
test("it comes in by what it was over, not by a step it guessed", () => {
const layer = setUp(4, 60, 60);
const radii = { rx: 44, ry: 44 };
place(layer, radii);
const over = worstOverhang(layer);
const settled = Render.settleSeatsOnTable(layer, 1, radii);
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
// measurement, not a step size that would have to be tuned per screen.
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
assert.equal(settled.ry, 42.5);
});
test("and it stops at the floor rather than collapsing into the pot", () => {
// A puck taller than the felt has an overhang no ring can fix. Pulling in
// forever would put every seat on top of the pot badge, which is a worse table
// than one with a puck touching the rail.
const layer = setUp(4, 60, 600);
const radii = { rx: 40, ry: 40 };
place(layer, radii);
const settled = Render.settleSeatsOnTable(layer, 1, radii);
assert.equal(settled.ry, Render.RING_MIN_R);
assert.ok(settled.ry > 0);
});
test("a table with nothing laid out yet is handed back untouched", () => {
Render.state = { seats: [], seatCount: 0 };
global.document = { getElementById: () => null };
const radii = { rx: 44, ry: 42 };
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
});
test("it is bounded — this runs after every tap a dealer makes", () => {
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
"each pass is a forced layout of the whole felt");
assert.ok(Render.SEAT_NAME_PASSES <= 3);
});
});
+383 -14
View File
@@ -32,6 +32,204 @@ const LayoutAudit = {
});
},
// ---- Every control says what it is ----
// WCAG 4.1.2 wants a name on every control, and empty button names are among the
// most common failures on the web — WebAIM's 2025 sweep found them on a bit over a
// quarter of home pages. This tool had one: the × that removes a chip colour, five
// of them in a column, identical, each deleting a different colour, and one click
// of the wrong one took $22,500 off a table.
//
// A symbol counts as no name here even though it is technically text. "×" is
// announced as "times" and names nothing — the rule is that a name has to contain a
// letter or a digit, which is the difference between "×" and "Undo" or "+ Add chip
// color". Anything with aria-hidden is not in the tree at all and is skipped rather
// than judged.
controlNames() {
const named = el => {
const aria = (el.getAttribute("aria-label") || "").trim();
if (aria) return aria;
const by = el.getAttribute("aria-labelledby");
if (by) {
const text = by.split(/\s+/)
.map(id => document.getElementById(id))
.filter(Boolean)
.map(node => (node.textContent || "").trim())
.join(" ")
.trim();
if (text) return text;
}
// A field's name usually comes from its label rather than from itself.
if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
const wrapping = el.closest("label");
const pointing = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null;
const text = [wrapping, pointing]
.filter(Boolean)
.map(node => (node.textContent || "").trim())
.join(" ")
.trim();
if (text) return text;
const placeholder = (el.getAttribute("placeholder") || "").trim();
if (placeholder) return placeholder;
}
const own = (el.textContent || "").trim();
if (own) return own;
const image = el.querySelector("img[alt]");
if (image) return (image.getAttribute("alt") || "").trim();
return (el.getAttribute("title") || "").trim();
};
const offenders = this.visibleControls()
.filter(el => !el.closest("[aria-hidden='true']"))
// A seat puck is in CONTROL_SELECTOR for the touch checks; its own name is
// the player's, and an empty chair is deliberately unnamed.
.filter(el => !el.classList.contains("seat"))
.map(el => ({ el, name: named(el) }))
.filter(({ name }) => !/[\p{L}\p{N}]/u.test(name))
.map(({ el, name }) => `${this.describe(el)} :: ${name ? `named only "${name}"` : "no accessible name"}`);
return { name: "every control has a name a screen reader can read", offenders };
},
// ---- Focus has somewhere to land ----
// Everything fixed to the bottom of the window, as one band. The action bar and the
// toast stack, so what matters is the top edge of the highest of them.
//
// Only things crossing the middle of the screen count. A landscape phone turns the
// bar into a rail down the right-hand side, which is bottom-anchored and full height
// — measuring that as "furniture at the bottom" would demand scroll padding the
// height of the whole window.
bottomFurniture() {
const middle = innerWidth / 2;
// A sticky element is anchored to whatever scrolls it, which is only the window
// when nothing between it and the root scrolls. The standings table has a sticky
// header inside a panel that scrolls on its own; it comes to rest against the
// bottom of the window often enough to be picked up here, and it is not
// furniture — scroll-padding on the document would do nothing about it.
const anchoredToWindow = node => {
if (getComputedStyle(node).position === "fixed") return true;
for (let up = node.parentElement; up && up !== document.body; up = up.parentElement) {
if (/auto|scroll|overlay/.test(getComputedStyle(up).overflowY)) return false;
}
return true;
};
return [...document.querySelectorAll("*")].filter(node => {
const cs = getComputedStyle(node);
if (cs.position !== "fixed" && cs.position !== "sticky") return false;
if (cs.visibility === "hidden" || cs.display === "none" || node.hidden) return false;
const b = node.getBoundingClientRect();
if (b.width === 0 || b.height === 0) return false;
if (b.bottom < innerHeight - 2 || b.left > middle || b.right < middle) return false;
return anchoredToWindow(node);
});
},
// WCAG 2.2 SC 2.4.11: a focused control must not be completely hidden by content the
// page put on top of it.
//
// A fixed bar is not in the flow, so the browser does not know it is there: Tab to a
// control near the bottom and it is scrolled flush against the edge of the window,
// under the bar, and the browser considers it done. Tabbing the whole page on a
// 390x844 phone found 16 stops resting completely covered and 27 more partly.
//
// Checked as the fix rather than the symptom, because the symptom needs a keyboard
// and this file has no automation of its own: scroll-padding-bottom on the scroll
// container has to reserve at least as much room as the furniture standing there.
focusHasRoom() {
const furniture = this.bottomFurniture();
if (furniture.length === 0) {
return { name: "focus has room under the fixed bar", offenders: [],
skipped: "nothing is fixed to the bottom here" };
}
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
const needed = Math.round(innerHeight - highest);
const declared = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom);
const reserved = Number.isFinite(declared) ? Math.round(declared) : 0;
const offenders = reserved >= needed ? [] : [
`${furniture.map(n => this.describe(n)).join(" + ")} stands ${needed}px tall, ` +
`and scroll-padding-bottom reserves ${reserved}px — a control tabbed to down there ` +
`comes to rest ${needed - reserved}px inside it`,
];
return { name: "focus has room under the fixed bar", offenders };
},
// ---- The furniture is not allowed to eat the table ----
// How much of the window the fixed bottom band is taking.
//
// The action bar's height is not a constant: it carries the pot breakdown, which had
// one row per all-in and no ceiling. Measured on a 360x640 phone — five players
// idle, 151px; six all-in, 303px; twelve all-in, 411px, which is 64% of the screen,
// leaving 96px of felt to draw twelve seats in. Nothing in the sweep could see it,
// because every check here asks whether a thing is covered or clipped and none of
// them asked how much room the covering thing had taken.
//
// Two lines, because there are two different claims. Half the window is the one
// worth failing on: furniture bigger than the content it serves is indefensible
// whatever it is showing, and it is a shape fault rather than a matter of taste.
// Thirty percent is the published guidance for persistent chrome and it is a warning
// here, because the shortest phone in the list does not meet it at rest — the bar is
// 151px of a 640px screen before anything happens — and a sweep that is always red
// stops being read.
furnitureBudget() {
const name = "the fixed bar leaves the table its room";
const furniture = this.bottomFurniture();
if (furniture.length === 0) {
return { name, offenders: [], skipped: "nothing is fixed to the bottom here" };
}
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
const tall = Math.round(innerHeight - highest);
const share = Math.round((100 * tall) / innerHeight);
const what = furniture.map(n => this.describe(n)).join(" + ");
return {
name,
offenders: share > 50
? [`${what} stands ${tall}px of a ${innerHeight}px window — ${share}%, ` +
`leaving the felt less room than the furniture`]
: [],
warnings: share > 30
? [`${name}: ${what} is ${share}% of the window (${tall}px of ${innerHeight}px); ` +
`persistent chrome is usually kept under 30%`]
: [],
};
},
// The other half: a control that scrolling can never reach at all.
//
// The seat editor is a bottom sheet on a phone, and it was put away with
// `transform: translateY(105%)` — which moves it and nothing else. Parked below an
// 844px window at y=850, its Done button was still in the tab order and still on the
// accessibility tree, and because a fixed element does not move when the page does,
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
// transform only moves it.
focusablesReachable() {
const offenders = this.visibleControls()
.filter(el => {
for (let node = el; node; node = node.parentElement) {
if (getComputedStyle(node).position === "fixed") return true;
}
return false;
})
.filter(el => {
const b = el.getBoundingClientRect();
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
})
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
return { name: "no control is parked where scrolling can't reach it", offenders };
},
// ---- Touch (see the mobile round) ----
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
@@ -769,6 +967,140 @@ const LayoutAudit = {
return { name: "hidden means hidden", offenders };
},
// A mark on the felt that stands for nothing.
//
// The amount pill inside every seat puck has a background, a pill radius and side
// padding, so a player who had not put chips in yet got an 8x11px green lozenge
// where their bet would go — measured on seven of nine seats at an ordinary table.
// One of those is a component holding its place; nine of them is a rash, and a
// dealer reading the felt has to learn to ignore a colour that on three other seats
// means "this player has money out".
//
// It cost room as well as attention. The ring is sized from the tallest puck on it,
// so a row nobody had filled was setting how far apart the seats had to sit.
//
// The rule is about paint, not about space: an element may hold its place invisibly,
// and several here do. What it may not do is draw a background or a border with
// nothing inside it.
//
// One real exemption, and it is on the list because the check reported it rather
// than because it was predicted: .betChipDisc is a 13x13px circle filled with the
// denomination colour of the chip beside it, published per chip from render.js. It
// has no text because the colour *is* the content. Anything carrying its own
// picture in a background-image is exempt on the same grounds, without needing to be
// named. Nothing else here needs an entry — three others were guessed at first and
// matched no element on the page, which is its own warning about writing this list
// from memory instead of from a run.
emptyDecoration() {
const DECORATIVE = new Set(["betChipDisc"]);
const opaque = value => value && value !== "none" && !/rgba\([^)]*,\s*0\s*\)/.test(value)
&& value !== "rgba(0, 0, 0, 0)" && value !== "transparent";
const offenders = [...document.querySelectorAll("#pokerTableWrap *")]
.filter(el => {
if (el.children.length || (el.textContent || "").trim()) return false;
if ([...el.classList].some(c => DECORATIVE.has(c))) return false;
const box = el.getBoundingClientRect();
if (box.width < 2 || box.height < 2) return false;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
if (opaque(cs.backgroundImage)) return false;
return opaque(cs.backgroundColor) ||
["Top", "Right", "Bottom", "Left"].some(side =>
parseFloat(cs[`border${side}Width`]) > 0 && opaque(cs[`border${side}Color`]));
})
.map(el => {
const box = el.getBoundingClientRect();
return `${this.describe(el)} :: nothing in it, painted ` +
`${Math.round(box.width)}x${Math.round(box.height)}px`;
});
return { name: "nothing on the felt is painted for nothing", offenders };
},
// The status a shut sidebar panel wears, and the three ways it can go wrong.
//
// Each of the eight <details> in the sidebar now carries the one fact you would
// have opened it for — "Balanced", "2 waiting", "5 colours · $1–$500". They are the
// sidebar's whole navigation, and what makes them scannable is that they are eight
// identical slabs: a glance that pushes one row taller than the rest costs more in
// rhythm than the fact it added is worth. Two of the strings aren't ours to keep
// short — a table note is whatever the dealer typed, and the amounts grow with the
// stakes — so this is checked on the page rather than argued about in the markup.
//
// Four questions, and they fail in different directions. A row that grew is the
// layout one. A glance sitting on its own label is the narrow-screen one, and it is
// the one that will come back: it takes a long label and a long glance in the same
// panel, so it depends on the state and the viewport together. And a glance
// ellipsised down to "Sam…" is worth less than no glance at all — it has spent a
// row's width to say that a note exists, which the panel's own presence already
// said.
GLANCE_MIN_READABLE_PX: 60,
glancesFitTheirRows() {
// Rows that are actually drawn. The Keyboard panel is display: none under
// `pointer: coarse` — a touch device has no keys to press — and a panel that
// isn't rendered measures 0px, which the shortest-row rule below happily took
// as its idea of a bare row. Every real row then read as grown: caught by the
// sweep on its first clean run, 91 offenders, seven rows across each of the
// thirteen viewports visited with a thumb.
const rows = [...document.querySelectorAll(".sidebarPanel > summary")]
.filter(row => row.getBoundingClientRect().height > 0);
if (!rows.length) return { name: "panel glances fit their rows", offenders: [], skipped: "no sidebar here" };
// The shortest row in the sidebar is what a row with nothing on it measures,
// whichever one that happens to be — taken rather than declared, so a change to
// the header's padding doesn't quietly make this check pass by moving the bar.
const heights = rows.map(row => row.getBoundingClientRect().height);
const bare = Math.min(...heights);
const offenders = [];
rows.forEach((row, index) => {
const glance = row.querySelector(".panelGlance");
const label = row.querySelector("h2");
const rowBox = row.getBoundingClientRect();
const name = (label && label.textContent.trim()) || `panel ${index + 1}`;
if (heights[index] > bare + 1) {
offenders.push(`${name} :: row is ${Math.round(heights[index])}px against ` +
`${Math.round(bare)}px for a row with nothing on it`);
}
if (!glance || glance.hidden || !glance.textContent.trim()) return;
const box = glance.getBoundingClientRect();
if (label) {
const labelBox = label.getBoundingClientRect();
if (box.left < labelBox.right) {
offenders.push(`${name} :: "${glance.textContent}" sits on the label, ` +
`${Math.round(labelBox.right - box.left)}px into it`);
}
}
// Both axes, and the vertical one is the half that matters. The row's own
// height cannot be trusted to report this: the first version of this check
// asked only whether a row had grown, and a glance floated back into flow
// and allowed to wrap sailed straight through it — a float doesn't expand
// the box it sits in, so two lines of note hung 9px clear of a row that
// still measured exactly 44px. Asking where the glance actually
// ended up is the question that has an answer.
const out = [
box.right > rowBox.right + 1 ? "off the right" : null,
box.left < rowBox.left - 1 ? "off the left" : null,
box.top < rowBox.top - 1 ? `${Math.round(rowBox.top - box.top)}px out of the top` : null,
box.bottom > rowBox.bottom + 1 ? `${Math.round(box.bottom - rowBox.bottom)}px out of the bottom` : null,
].filter(Boolean);
if (out.length) {
offenders.push(`${name} :: "${glance.textContent}" hangs ${out.join(" and ")} of its row`);
}
if (box.width < this.GLANCE_MIN_READABLE_PX &&
glance.scrollWidth > glance.clientWidth + 1) {
offenders.push(`${name} :: "${glance.textContent}" is cut down to ` +
`${Math.round(box.width)}px, which says less than nothing`);
}
});
return { name: "panel glances fit their rows", offenders };
},
// A seat's name, painted under the decoration on its own puck.
//
// The chip that marks the button sits in the puck's top-right corner on a layer
@@ -778,18 +1110,30 @@ const LayoutAudit = {
// concerned it had all the room it needed. Nothing else here would have caught it:
// the occlusion check is about controls a thumb has to reach, and this is a label
// a dealer has to read.
// Measured against the lines of text as they were painted, not against the box they
// live in. This used to take the box and subtract its padding, on the reasoning that
// "padding is room the text has already been kept out of" — which is true, and is
// not the only way to keep text out of somewhere. A right float does it too, and it
// does it per line, which is the point of using one: the name on the button seat is
// now indented past the chip on the line beside it and full width on the line below.
// Box-minus-padding cannot see that, and called every seat in the sweep an offender
// while the text sat exactly where it was supposed to.
//
// Range rects are the direct question — where did this text actually end up — so
// padding, floats, centring, wrapping and a short label in a wide box all come out
// right without any of them being modelled. Line boxes rather than one bounding box,
// too: a two-line name that clears the chip on the line beside it is not covered
// because its *other* line happens to sit under where the chip is.
seatLabelsClear() {
const offenders = [];
document.querySelectorAll(".seat").forEach(seat => {
const name = seat.querySelector(".seatPuckName");
if (!name || !name.textContent.trim()) return;
const box = name.getBoundingClientRect();
if (!box.width || !box.height) return;
// Padding is room the text has already been kept out of, so it is not
// somewhere the text can be covered.
const style = getComputedStyle(name);
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
const textRight = box.right - parseFloat(style.paddingRight || 0);
const range = document.createRange();
range.selectNodeContents(name);
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
if (!lines.length) return;
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
const over = mark.getBoundingClientRect();
@@ -797,10 +1141,15 @@ const LayoutAudit = {
const marked = getComputedStyle(mark);
if (marked.display === "none" || marked.visibility === "hidden" ||
parseFloat(marked.opacity) < 0.05) return;
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
if (covered > 1 && vertical > 1) {
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
let worst = 0;
lines.forEach(line => {
const across = Math.min(line.right, over.right) - Math.max(line.left, over.left);
const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top);
if (across > 1 && down > 1) worst = Math.max(worst, across);
});
if (worst > 1) {
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
}
});
});
@@ -862,14 +1211,28 @@ const LayoutAudit = {
// Along the line rather than at its middle: a badge takes the start
// of it and a neighbouring puck the end, and a single centre probe
// walks past both.
//
// Every probe, not the first one that hits. How much of the line is
// gone is the difference between a stack a dealer cannot read and a
// hairline where a badge's rounded corner grazes the last digit, and
// reporting both as "is under" gave no way to tell them apart — the
// one residual this sweep has carried for three rounds turns out to
// be one probe of seven.
let hits = 0;
let culprit = null;
let probes = 0;
for (let across = 0.08; across <= 0.92; across += 0.14) {
const x = line.left + (line.right - line.left) * across;
const y = (line.top + line.bottom) / 2;
probes++;
const over = document.elementFromPoint(x, y);
if (!over || seat.contains(over) || over.contains(seat)) continue;
offenders.push(
`seat ${index + 1} "${text}" is under ${this.describe(over)}`);
return;
hits++;
if (!culprit) culprit = over;
}
if (culprit) {
offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} `
+ `under ${this.describe(culprit)}`);
}
});
});
@@ -1049,6 +1412,10 @@ const LayoutAudit = {
const checks = [
this.seatsOnTheTable(options.seatSlack),
this.dialogActionsReachable(),
this.controlNames(),
this.focusHasRoom(),
this.focusablesReachable(),
this.furnitureBudget(),
this.touchAction(),
this.tapHighlight(),
this.textSelectable(),
@@ -1060,6 +1427,8 @@ const LayoutAudit = {
this.seatOwnership(),
this.oneDealerButton(),
this.hiddenButPainted(),
this.emptyDecoration(),
this.glancesFitTheirRows(),
this.seatLabelsClear(),
this.seatsKeepTheirText(),
this.chipsKeepTheirAmounts(),