Merge pull request 'Hold'em dealer: making the table and the bar fit the screen they are on' (#119) from polish/holdem-round-next into master
Build and Deploy / build-deploy (push) Successful in 1m40s
Build and Deploy / build-deploy (push) Successful in 1m40s
Reviewed-on: #119
This commit was merged in pull request #119.
This commit is contained in:
+3
-1
@@ -366,4 +366,6 @@ MigrationBackup/
|
|||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||||
native/**/build/
|
native/**/build/
|
||||||
|
# Playwright MCP scratch output
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
phone the felt did not start until 225px down — a quarter of the screen spent
|
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.
|
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
|
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
|
would have relocated the crowding rather than removed it: Reset Table down to
|
||||||
Session panel beside Clear history and the save/load pair, Quick Split down
|
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,
|
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. *@
|
and the way back to the site up top where a back link goes. *@
|
||||||
<div id="holdemHeaderLeft">
|
<div id="holdemHeaderLeft">
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
loaded: open on a table nobody has set up yet, shut on one already
|
loaded: open on a table nobody has set up yet, shut on one already
|
||||||
half-way through a night. *@
|
half-way through a night. *@
|
||||||
<details class="sidebarPanel" id="tableSetupPanel">
|
<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">
|
<div class="panelBody">
|
||||||
<label class="fieldRow">
|
<label class="fieldRow">
|
||||||
<span>Seats at table</span>
|
<span>Seats at table</span>
|
||||||
@@ -145,11 +146,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<details class="sidebarPanel">
|
||||||
<summary>Session</summary>
|
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<p id="storageWarning" class="storageWarning" hidden></p>
|
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||||
<h3 class="panelSubhead">Standings</h3>
|
|
||||||
<div class="standingsTableWrap">
|
<div class="standingsTableWrap">
|
||||||
<div id="standingsTable">
|
<div id="standingsTable">
|
||||||
<div class="standingsRow standingsHeader">
|
<div class="standingsRow standingsHeader">
|
||||||
@@ -165,14 +178,6 @@
|
|||||||
</div>
|
</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>
|
<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 id="bankReconcile" class="bankReconcile" hidden>
|
||||||
<div class="bankReconcileRow">
|
<div class="bankReconcileRow">
|
||||||
<span>Cash taken</span>
|
<span>Cash taken</span>
|
||||||
@@ -203,8 +208,24 @@
|
|||||||
<ul id="settleUpList" class="settleUpList"></ul>
|
<ul id="settleUpList" class="settleUpList"></ul>
|
||||||
<p id="settleUpNote" class="fieldHint"></p>
|
<p id="settleUpNote" class="fieldHint"></p>
|
||||||
</div>
|
</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">
|
<div class="handHistoryFilterRow">
|
||||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
<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" />
|
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="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Chip Legend</summary>
|
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<div id="chipLegendList"></div>
|
<div id="chipLegendList"></div>
|
||||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||||
@@ -253,7 +258,7 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel" id="keyboardPanel">
|
<details class="sidebarPanel" id="keyboardPanel">
|
||||||
<summary>Keyboard</summary>
|
<summary><h2>Keyboard</h2></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
<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>
|
<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>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Table Notes</summary>
|
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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 & 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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,6 +672,11 @@
|
|||||||
<link rel="manifest" href="/casino/holdem.webmanifest" />
|
<link rel="manifest" href="/casino/holdem.webmanifest" />
|
||||||
@* iOS reads none of the manifest's icons and only this. *@
|
@* iOS reads none of the manifest's icons and only this. *@
|
||||||
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
|
<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-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Hold'em" />
|
<meta name="apple-mobile-web-app-title" content="Hold'em" />
|
||||||
|
|||||||
@@ -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 {
|
#holdemApp {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -439,7 +475,7 @@ button,
|
|||||||
|
|
||||||
#holdemLayout {
|
#holdemLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 320px;
|
grid-template-columns: 1fr var(--sidebarW);
|
||||||
gap: var(--sp-5);
|
gap: var(--sp-5);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -933,8 +969,15 @@ button,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* A single word longer than the puck has nothing to break on. `anywhere` was the
|
/* 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
|
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
|
`break-word` the browser only breaks a word that cannot fit on a line of its own.
|
||||||
own, and `hyphens` puts a hyphen in when it does. */
|
|
||||||
|
`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;
|
overflow-wrap: break-word;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -946,14 +989,66 @@ button,
|
|||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The button's chip sits in this puck's top-right corner, on a layer above the text.
|
/* The button's chip sits in this puck's top-right corner, on a layer above the text, so
|
||||||
Reserving its width means a long name ends in an ellipsis before it reaches the
|
the name has to keep out from under it.
|
||||||
chip, rather than disappearing underneath one — on the one seat at the table a
|
|
||||||
dealer looks at most. */
|
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 {
|
.seat.seatHasButton .seatPuckName {
|
||||||
padding-right: 22px;
|
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 {
|
.seatPuckCards {
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -980,6 +1075,33 @@ button,
|
|||||||
line-height: 1.4;
|
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 {
|
.dealerChip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* Inside its own seat, not on the rim.
|
/* Inside its own seat, not on the rim.
|
||||||
@@ -1092,13 +1214,32 @@ button,
|
|||||||
color: #f0d98c;
|
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 {
|
.blindBadge {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -9px;
|
top: -5px;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: var(--sp-0) var(--sp-1);
|
line-height: 1.1;
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
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. */
|
was, and the chip is a layer above it. See render.js for the measurement. */
|
||||||
.seat.seatBelowCentre .blindBadge {
|
.seat.seatBelowCentre .blindBadge {
|
||||||
top: auto;
|
top: auto;
|
||||||
bottom: -9px;
|
bottom: -5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
|
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
|
||||||
@@ -1217,6 +1358,33 @@ button,
|
|||||||
font-size: var(--fs-2xs);
|
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
|
/* 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
|
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
|
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;
|
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 {
|
.panelBody {
|
||||||
padding-bottom: var(--sp-4);
|
padding-bottom: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2532,6 +2771,10 @@ button,
|
|||||||
/* ---------- Action bar ---------- */
|
/* ---------- Action bar ---------- */
|
||||||
|
|
||||||
#actionBar {
|
#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;
|
position: fixed;
|
||||||
/* Centred with auto margins rather than 100vw + translate: on a phone with a
|
/* 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
|
scrollbar, 100vw is wider than the viewport and the overflow drags the whole
|
||||||
@@ -2554,9 +2797,52 @@ button,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */
|
/* The bar is fixed over the page, so the page needs room to scroll past it.
|
||||||
body.hasActionBar #holdemSidebar {
|
|
||||||
padding-bottom: 9rem;
|
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 {
|
.actionStatus {
|
||||||
@@ -2877,12 +3163,32 @@ body.hasActionBar #holdemSidebar {
|
|||||||
margin-bottom: var(--sp-4);
|
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 .potLayerLabel,
|
||||||
.potLayerReturn .potLayerAmount {
|
.potLayerReturn .potLayerAmount {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
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 {
|
.actionButtons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--sp-2);
|
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
|
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
|
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
|
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
|
that was crowded at 240 comes out clean.
|
||||||
constraint — 390x844 and up — nothing changes and the table stays round. */
|
|
||||||
|
"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 {
|
#pokerTableWrap {
|
||||||
|
--feltAspect: 1.5;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: var(--feltRoomPx, 100vh);
|
max-height: var(--feltRoomPx, 100vh);
|
||||||
}
|
}
|
||||||
@@ -3748,11 +4102,6 @@ body.hasActionBar #toastHost {
|
|||||||
grid-template-columns: 1fr;
|
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 —
|
/* 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
|
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
|
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 ----------
|
/* ---------- Phones ----------
|
||||||
Two things break on a narrow screen: seat pucks ring the table on a radius
|
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
|
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));
|
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
||||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||||
transform: translateY(105%);
|
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;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.seatSheetOpen #seatEditorPanel {
|
body.seatSheetOpen #seatEditorPanel {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
|
visibility: visible;
|
||||||
|
transition: transform 0.25s ease, visibility 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatSheetGrip {
|
.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 ----------
|
/* ---------- Keyboard focus ----------
|
||||||
Three focus rules existed for the whole tool, so tabbing through it relied on
|
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
|
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"]),
|
.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
|
||||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||||
.subPanelBody 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;
|
min-height: 44px;
|
||||||
padding: var(--sp-2) var(--sp-2);
|
padding: var(--sp-2) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,17 @@ const ActionBar = {
|
|||||||
|
|
||||||
const publish = () => {
|
const publish = () => {
|
||||||
const visible = !host.hidden;
|
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`);
|
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
|
// Kept so anything that moves the table without touching the bar can ask for
|
||||||
@@ -50,6 +58,33 @@ const ActionBar = {
|
|||||||
publish();
|
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
|
// 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
|
// 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
|
// 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)])
|
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(
|
return Render.buildPotBreakdown(
|
||||||
PotCalculator.layers(contributions),
|
PotCalculator.layers(contributions),
|
||||||
PotCalculator.uncalledReturns(contributions),
|
PotCalculator.uncalledReturns(contributions),
|
||||||
id => nameById.get(id)
|
id => nameById.get(id),
|
||||||
|
null,
|
||||||
|
{ maxLayers: 4 }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ const Toast = {
|
|||||||
|
|
||||||
host.appendChild(toast);
|
host.appendChild(toast);
|
||||||
// Next frame, so the entrance transition actually runs.
|
// Next frame, so the entrance transition actually runs.
|
||||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add("toastVisible");
|
||||||
|
this.publishHeight();
|
||||||
|
});
|
||||||
|
|
||||||
this.timer = setTimeout(
|
this.timer = setTimeout(
|
||||||
() => this.dismiss(),
|
() => this.dismiss(),
|
||||||
@@ -61,6 +64,24 @@ const Toast = {
|
|||||||
}
|
}
|
||||||
const host = document.getElementById("toastHost");
|
const host = document.getElementById("toastHost");
|
||||||
if (host) host.innerHTML = "";
|
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));
|
() => HoldemState.removeFromWaitingList(state, id));
|
||||||
},
|
},
|
||||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
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 = {
|
const seatMenuHooks = {
|
||||||
@@ -467,6 +515,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||||
state.notes = e.target.value;
|
state.notes = e.target.value;
|
||||||
hooks.save(state);
|
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", () => {
|
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||||
@@ -616,6 +670,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}, 0);
|
}, 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);
|
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const Render = {
|
|||||||
this.renderNotes();
|
this.renderNotes();
|
||||||
this.updatePotBadge();
|
this.updatePotBadge();
|
||||||
this.renderSetupFields();
|
this.renderSetupFields();
|
||||||
|
this.renderPanelGlances();
|
||||||
this.renderHeaderActions();
|
this.renderHeaderActions();
|
||||||
this.renderTableHint();
|
this.renderTableHint();
|
||||||
this.renderStandings();
|
this.renderStandings();
|
||||||
@@ -65,6 +66,105 @@ const Render = {
|
|||||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// two font sizes and coarse enough to settle in ten tries.
|
||||||
SEAT_WIDTH_STEP: 6,
|
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
|
// 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
|
// typography drops to the tight set in one go. Width scales continuously; type
|
||||||
// changes deliberately, which is the split the modern guidance actually draws —
|
// 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
|
// `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
|
// takes no room between two seats; what it must not do is reach *sideways*, which is
|
||||||
// what seatFootprint.test.js holds the stylesheet to.
|
// 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.
|
// 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 —
|
// 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.
|
// 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
|
// Its remaining overhang is vertical: `top: -9px` on the upper arc, `bottom: -9px`
|
||||||
// *away* from the table: `top: -9px` on the upper arc, `bottom: -9px` on the lower
|
// on the lower one, so it always points away from the table and two seats facing
|
||||||
// one, so two seats facing each other across the felt wear their badges back to
|
// each other across the felt wear their badges back to back.
|
||||||
// back. Two seats on the same arc wear them at the same height, offset by exactly
|
|
||||||
// as much as the pucks are.
|
|
||||||
//
|
//
|
||||||
// Adding it anyway was tried first, and it broke the case it was least expected to:
|
// That is why it was left out of this for a while, and the reasoning has one hole
|
||||||
// a phone held sideways gives the ring 87px of height, two rows of 55px pucks leave
|
// in it. "Two seats on the same arc wear them at the same height" is true of a
|
||||||
// 18px between them, and demanding 18px more for badges that point outward made a
|
// straight rail and false of a ring: neighbours on a curve sit at different heights,
|
||||||
// six-handed landscape table unsatisfiable at any width. Twelve-seat tables on three
|
// so the badge that points away from the table on one of them points at the corner
|
||||||
// phones went the same way. The clearance below is what keeps rounded corners from
|
// of the other. Measured, three tables in the sweep had a player's stack painted
|
||||||
// reading as a touch.
|
// 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) {
|
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) {
|
applySeatWidth(layer, width) {
|
||||||
layer.style.setProperty("--seatPuckW", `${width}px`);
|
layer.style.setProperty("--seatPuckW", `${width}px`);
|
||||||
layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_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.
|
// Widest first, because the widest that fits is the one we want.
|
||||||
seatWidthCandidates() {
|
seatWidthCandidates(count, box) {
|
||||||
const widths = [];
|
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) {
|
for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) {
|
||||||
widths.push(w);
|
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
|
// 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
|
// name wrap to a second line belongs here, because the height that comes back from
|
||||||
// measuring is what the ring is sized against.
|
// 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() {
|
seatShapeSignature() {
|
||||||
if (!this.state) return "";
|
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.
|
// Which width the pucks get, and how wide a ring they sit on.
|
||||||
@@ -586,7 +787,7 @@ const Render = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const width of this.seatWidthCandidates()) {
|
for (const width of this.seatWidthCandidates(count, box)) {
|
||||||
this.applySeatWidth(layer, width);
|
this.applySeatWidth(layer, width);
|
||||||
const puck = this.measuredSeatBox();
|
const puck = this.measuredSeatBox();
|
||||||
if (!puck) {
|
if (!puck) {
|
||||||
@@ -1135,19 +1336,7 @@ const Render = {
|
|||||||
this.state.seats.forEach((player, index) => {
|
this.state.seats.forEach((player, index) => {
|
||||||
const node = layer.children[index];
|
const node = layer.children[index];
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
this.placeSeat(node, index, 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);
|
|
||||||
|
|
||||||
// The chair number is set once, in buildSeatNodes — a seat's index is the
|
// 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.
|
// one thing about it that cannot change while the node exists.
|
||||||
@@ -1200,10 +1389,169 @@ const Render = {
|
|||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
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
|
// 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().
|
// changes what is in the middle of the felt — see placeBoard().
|
||||||
this.lastBetLayout = { aspect, radii };
|
this.lastBetLayout = { aspect, radii: settled };
|
||||||
this.renderBets(aspect, radii);
|
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) {
|
isRedSuit(card) {
|
||||||
@@ -1698,6 +2046,12 @@ const Render = {
|
|||||||
// after naming a player both stayed greyed out, still captioned "nobody is at
|
// after naming a player both stayed greyed out, still captioned "nobody is at
|
||||||
// the table yet", because this path deliberately doesn't redraw everything.
|
// the table yet", because this path deliberately doesn't redraw everything.
|
||||||
this.renderHeaderActions();
|
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) {
|
wireSeatFields(form, player, index) {
|
||||||
@@ -1846,25 +2200,52 @@ const Render = {
|
|||||||
const row = template.content.firstElementChild.cloneNode(true);
|
const row = template.content.firstElementChild.cloneNode(true);
|
||||||
const colorInput = row.querySelector(".chipColorInput");
|
const colorInput = row.querySelector(".chipColorInput");
|
||||||
const valueInput = row.querySelector(".chipValueInput");
|
const valueInput = row.querySelector(".chipValueInput");
|
||||||
|
const removeBtn = row.querySelector(".removeChipColorBtn");
|
||||||
colorInput.value = chip.color;
|
colorInput.value = chip.color;
|
||||||
valueInput.value = chip.value;
|
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", () => {
|
colorInput.addEventListener("input", () => {
|
||||||
chip.color = colorInput.value;
|
chip.color = colorInput.value;
|
||||||
this.hooks.save(this.state);
|
this.hooks.save(this.state);
|
||||||
});
|
});
|
||||||
valueInput.addEventListener("input", () => {
|
valueInput.addEventListener("input", () => {
|
||||||
chip.value = Number(valueInput.value) || 0;
|
chip.value = Number(valueInput.value) || 0;
|
||||||
|
nameRemove();
|
||||||
this.hooks.save(this.state);
|
this.hooks.save(this.state);
|
||||||
this.renderSeats();
|
this.renderSeats();
|
||||||
this.renderSeatEditor();
|
this.renderSeatEditor();
|
||||||
this.updatePotBadge();
|
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", () => {
|
// Through the hook rather than done here, because what this costs depends on
|
||||||
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
|
// what the seats are holding — see removeChipColor in main.js.
|
||||||
this.hooks.save(this.state);
|
removeBtn.addEventListener("click", () => this.hooks.removeChipColor(chip.id));
|
||||||
this.renderAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -1923,7 +2304,15 @@ const Render = {
|
|||||||
|
|
||||||
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
|
// 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.
|
// 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;
|
if (pots.length < 2 && returns.length === 0) return null;
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -1936,7 +2325,7 @@ const Render = {
|
|||||||
wrap.appendChild(title);
|
wrap.appendChild(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRow = (label, amount, who, className) => {
|
const addRow = (label, amount, who, className, full) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = `potLayer ${className || ""}`;
|
row.className = `potLayer ${className || ""}`;
|
||||||
|
|
||||||
@@ -1951,6 +2340,11 @@ const Render = {
|
|||||||
const whoEl = document.createElement("span");
|
const whoEl = document.createElement("span");
|
||||||
whoEl.className = "potLayerWho";
|
whoEl.className = "potLayerWho";
|
||||||
whoEl.textContent = who;
|
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(labelEl);
|
||||||
row.appendChild(amountEl);
|
row.appendChild(amountEl);
|
||||||
@@ -1958,12 +2352,74 @@ const Render = {
|
|||||||
wrap.appendChild(row);
|
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"));
|
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||||||
|
|
||||||
return wrap;
|
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 ----
|
// ---- Session standings ----
|
||||||
|
|
||||||
renderStandings() {
|
renderStandings() {
|
||||||
|
|||||||
@@ -1139,6 +1139,84 @@ const HoldemState = {
|
|||||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
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.
|
// 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
|
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||||
|
|||||||
+465
-1
@@ -22,6 +22,11 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
|||||||
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
| `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 |
|
| `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) |
|
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||||
|
|
||||||
## The layout audit
|
## 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
|
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
|
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
|
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
|
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:
|
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 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,
|
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.
|
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
|
"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
|
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
|
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
|
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)".
|
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
|
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
|
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
|
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
|
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.
|
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.
|
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
|
`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
|
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
|
That is how the standings table shipped with a player-name column zero pixels wide
|
||||||
and kept it for two rounds.
|
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
|
## 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
|
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)));
|
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
|
// 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,
|
// 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([
|
const fromScript = new Set([
|
||||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||||
|
"--toastHeight",
|
||||||
]);
|
]);
|
||||||
// Defined by the site's own stylesheet, not this one.
|
// Defined by the site's own stylesheet, not this one.
|
||||||
const fromSite = new Set(["--font-mono"]);
|
const fromSite = new Set(["--font-mono"]);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
|
|||||||
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,12 +40,21 @@ const OVERLAYS = [
|
|||||||
// sitting down more than on anything a viewport can tell you. A full table is where the
|
// 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
|
// 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.
|
// 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 = [
|
const TABLES = [
|
||||||
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||||
["Bob", 250], ["Dee", 600]], false],
|
["Bob", 250], ["Dee", 600]], false],
|
||||||
["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
["packed", 12, PACKED],
|
||||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
// The same twelve, every one of them all-in for a different amount. This is the
|
||||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]],
|
// 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
|
// 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
|
// 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
|
// 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) {
|
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||||
await page.setViewportSize({ width, height });
|
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}`;
|
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||||
await page.evaluate(seedTable, [seats, players, board]);
|
await page.evaluate(seedTable, [seats, players, board]);
|
||||||
@@ -133,7 +142,36 @@ async function main() {
|
|||||||
if (deal) deal.click();
|
if (deal) deal.click();
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(220);
|
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 acted = await page.evaluate(() => {
|
||||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||||
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
.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) });
|
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}`));
|
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
|
// 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
|
// opened over the half-full one — twice the viewports is enough time
|
||||||
// spent already.
|
// spent already.
|
||||||
|
|||||||
@@ -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", () => {
|
describe("with no browser to measure", () => {
|
||||||
test("nothing here throws", () => {
|
test("nothing here throws", () => {
|
||||||
assert.equal(Render.measuredSeatBox(), null);
|
assert.equal(Render.measuredSeatBox(), null);
|
||||||
assert.equal(Render.seatFootprint(null), null);
|
assert.equal(Render.seatFootprint(null), null);
|
||||||
assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 & 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 & reset");
|
||||||
|
assert.equal(panelHolding("loadTableBtn"), "Save & reset");
|
||||||
|
assert.equal(panelHolding("resetTableBtn"), "Save & reset");
|
||||||
|
assert.equal(panels()[panels().length - 1].name, "Save & reset",
|
||||||
|
"the panel that ends the night belongs at the end, away from the working ones");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no panel goes back to being a grab-bag", () => {
|
||||||
|
// Not a size limit — Table Setup is legitimately long and Chip Legend legitimately
|
||||||
|
// repeats a row per chip. What this counts is *subjects*: a panel that needs its own
|
||||||
|
// headings to say what its parts are is a panel holding more than one thing, which
|
||||||
|
// is what Session was and how it got that way.
|
||||||
|
test("at most one subject needs a heading inside a panel", () => {
|
||||||
|
panels().forEach(p => {
|
||||||
|
const subheads = [...p.markup.matchAll(/<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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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) ----
|
// ---- Touch (see the mobile round) ----
|
||||||
|
|
||||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||||
@@ -769,6 +967,140 @@ const LayoutAudit = {
|
|||||||
return { name: "hidden means hidden", offenders };
|
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.
|
// 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
|
// 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:
|
// 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
|
// the occlusion check is about controls a thumb has to reach, and this is a label
|
||||||
// a dealer has to read.
|
// 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() {
|
seatLabelsClear() {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
document.querySelectorAll(".seat").forEach(seat => {
|
document.querySelectorAll(".seat").forEach(seat => {
|
||||||
const name = seat.querySelector(".seatPuckName");
|
const name = seat.querySelector(".seatPuckName");
|
||||||
if (!name || !name.textContent.trim()) return;
|
if (!name || !name.textContent.trim()) return;
|
||||||
const box = name.getBoundingClientRect();
|
|
||||||
if (!box.width || !box.height) return;
|
const range = document.createRange();
|
||||||
// Padding is room the text has already been kept out of, so it is not
|
range.selectNodeContents(name);
|
||||||
// somewhere the text can be covered.
|
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||||||
const style = getComputedStyle(name);
|
if (!lines.length) return;
|
||||||
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
|
||||||
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
|
||||||
|
|
||||||
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||||
const over = mark.getBoundingClientRect();
|
const over = mark.getBoundingClientRect();
|
||||||
@@ -797,10 +1141,15 @@ const LayoutAudit = {
|
|||||||
const marked = getComputedStyle(mark);
|
const marked = getComputedStyle(mark);
|
||||||
if (marked.display === "none" || marked.visibility === "hidden" ||
|
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||||
parseFloat(marked.opacity) < 0.05) return;
|
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);
|
let worst = 0;
|
||||||
if (covered > 1 && vertical > 1) {
|
lines.forEach(line => {
|
||||||
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
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
|
// 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
|
// of it and a neighbouring puck the end, and a single centre probe
|
||||||
// walks past both.
|
// 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) {
|
for (let across = 0.08; across <= 0.92; across += 0.14) {
|
||||||
const x = line.left + (line.right - line.left) * across;
|
const x = line.left + (line.right - line.left) * across;
|
||||||
const y = (line.top + line.bottom) / 2;
|
const y = (line.top + line.bottom) / 2;
|
||||||
|
probes++;
|
||||||
const over = document.elementFromPoint(x, y);
|
const over = document.elementFromPoint(x, y);
|
||||||
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
||||||
offenders.push(
|
hits++;
|
||||||
`seat ${index + 1} "${text}" is under ${this.describe(over)}`);
|
if (!culprit) culprit = over;
|
||||||
return;
|
}
|
||||||
|
if (culprit) {
|
||||||
|
offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} `
|
||||||
|
+ `under ${this.describe(culprit)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1049,6 +1412,10 @@ const LayoutAudit = {
|
|||||||
const checks = [
|
const checks = [
|
||||||
this.seatsOnTheTable(options.seatSlack),
|
this.seatsOnTheTable(options.seatSlack),
|
||||||
this.dialogActionsReachable(),
|
this.dialogActionsReachable(),
|
||||||
|
this.controlNames(),
|
||||||
|
this.focusHasRoom(),
|
||||||
|
this.focusablesReachable(),
|
||||||
|
this.furnitureBudget(),
|
||||||
this.touchAction(),
|
this.touchAction(),
|
||||||
this.tapHighlight(),
|
this.tapHighlight(),
|
||||||
this.textSelectable(),
|
this.textSelectable(),
|
||||||
@@ -1060,6 +1427,8 @@ const LayoutAudit = {
|
|||||||
this.seatOwnership(),
|
this.seatOwnership(),
|
||||||
this.oneDealerButton(),
|
this.oneDealerButton(),
|
||||||
this.hiddenButPainted(),
|
this.hiddenButPainted(),
|
||||||
|
this.emptyDecoration(),
|
||||||
|
this.glancesFitTheirRows(),
|
||||||
this.seatLabelsClear(),
|
this.seatLabelsClear(),
|
||||||
this.seatsKeepTheirText(),
|
this.seatsKeepTheirText(),
|
||||||
this.chipsKeepTheirAmounts(),
|
this.chipsKeepTheirAmounts(),
|
||||||
|
|||||||
Reference in New Issue
Block a user