Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
171e878262 | ||
|
|
0f7213ec2d | ||
|
|
853fcedea0 | ||
|
|
295d9eff4a | ||
|
|
fafbd8df32 | ||
|
|
0d878a176d | ||
|
|
7216f3da4d | ||
|
|
e2bb935b43 | ||
|
|
75ec028c3e | ||
|
|
e90bcccada | ||
|
|
14fb68dc04 | ||
|
|
4a1cb842cb | ||
|
|
e7ddff2fed | ||
|
|
f8ed4abc85 | ||
|
|
942919f3f7 | ||
|
|
02c2a034cb | ||
|
|
cd2db6346e | ||
|
|
6eb97c8a35 | ||
|
|
bd8ee11f77 | ||
|
|
13d704301d | ||
|
|
7bd205a91a | ||
|
|
f07144acd8 | ||
|
|
fbc4bd9a3e |
+3
-1
@@ -366,4 +366,6 @@ MigrationBackup/
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
native/**/build/
|
||||
# Playwright MCP scratch output
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -11,22 +11,43 @@
|
||||
phone the felt did not start until 225px down — a quarter of the screen spent
|
||||
before the dealer saw the table. Only two of the six are per-hand controls.
|
||||
The rest have gone where they belong rather than into a "more" menu, which
|
||||
would have relocated the crowding rather than removed it: Reset Table to the
|
||||
Session panel beside Clear history and the save/load pair, Quick Split down
|
||||
would have relocated the crowding rather than removed it: Reset Table down to
|
||||
the sidebar with the save/load pair — in a panel called Save & reset, so the
|
||||
danger is on the label rather than at the end of a scroll — Quick Split down
|
||||
with Common rulings where the other "open something else" link already lives,
|
||||
and the way back to the site up top where a back link goes. *@
|
||||
@* The word is its own element so a phone can drop it and keep the arrow, and the
|
||||
aria-label is what a screen reader gets either way. It contains the visible
|
||||
text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name
|
||||
that says more than the label does. *@
|
||||
<div id="holdemHeaderLeft">
|
||||
<a href="/casino" class="holdemBackLink">← Casino</a>
|
||||
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
@* Not the accent any more. It is a shortcut to a sheet, available all night,
|
||||
and it is never the next step — the bar always has that, and at showdown
|
||||
the bar says so in as many words. See .actionAdvance in holdem.css. *@
|
||||
<button id="resolvePotBtn" class="holdemBtn">Resolve Pot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@* The seat sheet was the only one of the tool's seven overlays with nothing behind
|
||||
it. On a phone it covers the felt from 250px down and the action bar with it, so
|
||||
it is modal in everything but appearance — and what stayed lit above it was the
|
||||
header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was
|
||||
reaching for a name field. NN/g and Material both say the same thing about this:
|
||||
a modal sheet with an invisible scrim tells the user they can still touch what
|
||||
they can still see, and they can't.
|
||||
|
||||
Not an `*Overlay` id, deliberately — Modal watches those and would take the
|
||||
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
|
||||
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
|
||||
just a panel. *@
|
||||
<div id="seatSheetScrim"></div>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="boardStrip" hidden></div>
|
||||
@@ -67,7 +88,7 @@
|
||||
loaded: open on a table nobody has set up yet, shut on one already
|
||||
half-way through a night. *@
|
||||
<details class="sidebarPanel" id="tableSetupPanel">
|
||||
<summary>Table Setup</summary>
|
||||
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
@@ -86,10 +107,21 @@
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Track the board and read the showdown</span>
|
||||
<span>Ask me for the cards as they come out</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Puts the community cards on the felt and asks for them as each street is dealt. Turn the players’ cards over at showdown and Resolve Pot works out who won and how much, instead of asking you.</p>
|
||||
@* The label used to lead with "Track the board", which is the feature's name
|
||||
rather than what pressing it does to your evening. What it does is put a
|
||||
card sheet in front of you at four moments: each street, and the showdown. *@
|
||||
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Keep the screen on during a hand</span>
|
||||
<input type="checkbox" id="keepScreenAwakeToggle" />
|
||||
</label>
|
||||
@* On by default, and the only setting here that is. A dealer has to
|
||||
opt in to the tool doing more than they asked; they should not have
|
||||
to opt in to it staying readable. *@
|
||||
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks — and the tool says so the first time it takes hold.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
@@ -145,11 +177,23 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* These four were one panel called "Session", which is a container word rather
|
||||
than a name: it held the standings, a waiting list, the hand log and the
|
||||
save/load/reset buttons — a topic, a task, a log and three utilities, which
|
||||
is four kinds of thing under one label. It measured at 1206px and 103
|
||||
elements, and "The table itself" was 1052px down inside it, so reaching
|
||||
Reset table meant opening a panel called Session and scrolling past the
|
||||
money and the log to get there.
|
||||
|
||||
Each panel is named for the thing a dealer came looking for, and the
|
||||
subheads that used to do that job are gone, because the summary says it
|
||||
now. They are in the order of a night: set the table up, watch the money,
|
||||
fill the seats, look back at a hand — and, last and apart from the rest,
|
||||
the two buttons that end it. *@
|
||||
<details class="sidebarPanel">
|
||||
<summary>Session</summary>
|
||||
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||
<h3 class="panelSubhead">Standings</h3>
|
||||
<div class="standingsTableWrap">
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
@@ -165,14 +209,6 @@
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||
|
||||
<h3 class="panelSubhead">Waiting list</h3>
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
|
||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
@@ -203,8 +239,24 @@
|
||||
<ul id="settleUpList" class="settleUpList"></ul>
|
||||
<p id="settleUpNote" class="fieldHint"></p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<div class="handHistoryFilterRow">
|
||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
|
||||
@@ -218,27 +270,11 @@
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">The table itself</h3>
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
@* Down from the header, where it sat in the same weight as Resolve Pot
|
||||
— the most-pressed control in a session next to the one that throws
|
||||
the night away. Here it is beside Clear history, which is the other
|
||||
thing that destroys records, and behind a panel you have to open. *@
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Chip Legend</summary>
|
||||
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
@@ -253,7 +289,7 @@
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel" id="keyboardPanel">
|
||||
<summary>Keyboard</summary>
|
||||
<summary><h2>Keyboard</h2></summary>
|
||||
<div class="panelBody">
|
||||
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
||||
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
|
||||
@@ -261,11 +297,45 @@
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* Last, and on its own, because everything in it is about the night as a whole
|
||||
rather than the hand in front of you — and because two of the three buttons
|
||||
are the ones you cannot take back.
|
||||
|
||||
Reset table came down from the header a while ago, where it sat in the same
|
||||
weight as Resolve Pot: the most-pressed control in a session beside the one
|
||||
that throws the night away. It used to be buried at the bottom of Session,
|
||||
1052px down, which hid it but also meant nobody could find it on purpose.
|
||||
Naming the panel for both jobs puts the danger on the label, where it is
|
||||
read before the panel is opened rather than met at the end of a scroll. *@
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Save & 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>
|
||||
@* That sentence has always been true, and always been missing its
|
||||
second half: a browser is allowed to throw site data away when the
|
||||
device runs short, and Safari does it on a timer. So the panel now
|
||||
says which of the two situations this device is actually in, and
|
||||
offers the one thing that changes it. See requestDurable() in
|
||||
state.js for why the ask is a button rather than a page-load call. *@
|
||||
<p class="fieldHint" id="storageDurabilityHint" hidden></p>
|
||||
<button id="keepStorageBtn" class="holdemBtn holdemBtnSmall" hidden>Ask this browser to keep it</button>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,6 +354,20 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@* Four things stacked flat used to be five: name, in/out, two hole-card slots, stack,
|
||||
a five-box chip counter and a list of custom fields, every one of them at the same
|
||||
weight, all of it open at once. Measured on a 390px phone the sheet came to 594px,
|
||||
and the three controls a dealer touches on nearly every visit — the name, the two
|
||||
status boxes, the stack — were 132px of it. The chip counter alone was 174px, the
|
||||
tallest thing in the sheet, for a job most tables do once a night if at all; the two
|
||||
card slots were 62px of dashed grey on every table that never asked to be shown a
|
||||
card.
|
||||
|
||||
So the flat list is now three rows and three disclosures, and which of the three
|
||||
stand open is a question about *this seat*, answered in renderSeatDisclosures: a
|
||||
seat holding cards opens its cards, a seat with chips counted opens its count, a
|
||||
seat with notes on it opens its notes. Anything with nothing in it stays a one-line
|
||||
summary that says so. Nothing was removed and nothing moved to another screen. *@
|
||||
<template id="seatEditorTemplate">
|
||||
<div class="seatEditorForm">
|
||||
<label class="fieldRow">
|
||||
@@ -300,25 +384,45 @@
|
||||
<span>Fold</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="seatCards">
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</div>
|
||||
<label class="fieldRow stackRow">
|
||||
<span>Stack</span>
|
||||
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
|
||||
</label>
|
||||
<div class="seatBuyIns" hidden></div>
|
||||
<div class="seatContribution">
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
<span>$ amount</span>
|
||||
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
|
||||
</label>
|
||||
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||
</div>
|
||||
<div class="customFields"></div>
|
||||
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
|
||||
|
||||
<details class="subPanel seatSubPanel cardsPanel">
|
||||
<summary><span class="seatSubPanelName">Hole cards</span> <span class="seatGlance cardsGlance"></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="seatCards">
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* Named for the question it answers rather than for the boxes it contains. The
|
||||
counter, the typed override and the total are one calculation — what this
|
||||
seat has put in the pot — and the answer now sits on the summary, so a shut
|
||||
panel still says it. *@
|
||||
<details class="subPanel seatSubPanel contributionPanel">
|
||||
<summary><span class="seatSubPanelName">What they've put in</span> <span class="seatGlance contributionGlance"></span></summary>
|
||||
<div class="subPanelBody seatContribution">
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
<span>$ amount</span>
|
||||
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
|
||||
</label>
|
||||
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subPanel seatSubPanel notesPanel">
|
||||
<summary><span class="seatSubPanelName">Notes on this seat</span> <span class="seatGlance notesGlance"></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="customFields"></div>
|
||||
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -330,9 +434,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is
|
||||
nothing at all on the device this is used on. Five coloured dots with five number
|
||||
boxes and no way to tell which one was the $25 without opening the Chip Legend and
|
||||
matching colours by eye. It is text now. *@
|
||||
<template id="chipCountTemplate">
|
||||
<label class="chipCountRow">
|
||||
<span class="chipSwatch"></span>
|
||||
<span class="chipCountValue"></span>
|
||||
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
|
||||
</label>
|
||||
</template>
|
||||
@@ -641,6 +750,11 @@
|
||||
<link rel="manifest" href="/casino/holdem.webmanifest" />
|
||||
@* iOS reads none of the manifest's icons and only this. *@
|
||||
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
|
||||
@* The standard name first, then Apple's older one. Chrome has been printing
|
||||
"<meta name="apple-mobile-web-app-capable"> is deprecated" into this page's
|
||||
console on every load; the replacement is the unprefixed name, and Safari still
|
||||
wants the prefixed one, so both are here. *@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Hold'em" />
|
||||
@@ -663,6 +777,8 @@
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
|
||||
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -53,6 +53,39 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
|
||||
// on it and nothing warns you when it breaks.
|
||||
//
|
||||
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
|
||||
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
|
||||
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
|
||||
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
|
||||
// capital-C spelling registered the worker, activated it, logged nothing — and then left
|
||||
// the cache completely empty, because the page was never controlled and the precache
|
||||
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
|
||||
// that looked loaded and had nothing stored for the night the signal went.
|
||||
//
|
||||
// So one spelling wins and the other is redirected to it, permanently, before anything
|
||||
// else in the pipeline gets a look.
|
||||
//
|
||||
// Deliberately scoped to /casino rather than applied site-wide. Every file under
|
||||
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
|
||||
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
|
||||
// not hold for the rest of the site, and one that /chessHub in particular would break.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
if (path is not null
|
||||
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
|
||||
&& path.Any(char.IsUpper))
|
||||
{
|
||||
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
|
||||
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
|
||||
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,9 +32,17 @@ const ActionBar = {
|
||||
|
||||
const publish = () => {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
const rail = this.isRail(host);
|
||||
// On a phone held sideways the bar is a rail down the right-hand side, so it
|
||||
// takes no height at all — and `--actionBarHeight` is what four other rules
|
||||
// and the felt measurement all mean by "how much of the bottom is spoken
|
||||
// for". Publishing the rail's height there would reserve the whole window.
|
||||
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
this.publishFeltRoom(height);
|
||||
// What is under the felt is the bar in one layout and the two links in the
|
||||
// other. Either way it is the same question — how much of the bottom is
|
||||
// already spoken for — so it is the same argument.
|
||||
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
|
||||
};
|
||||
|
||||
// Kept so anything that moves the table without touching the bar can ask for
|
||||
@@ -50,6 +58,33 @@ const ActionBar = {
|
||||
publish();
|
||||
},
|
||||
|
||||
// Is the bar a rail down the side rather than a bar across the bottom?
|
||||
//
|
||||
// Asked of the stylesheet rather than answered again here. The layout is chosen by
|
||||
// a media query — orientation, height and width together — and a second copy of
|
||||
// that query written in JavaScript is a copy that drifts: the first thing anyone
|
||||
// changes is the breakpoint, in one of the two places. So the CSS declares which
|
||||
// layout it picked in a custom property and this reads the answer back.
|
||||
isRail(host) {
|
||||
if (!host || typeof getComputedStyle !== "function") return false;
|
||||
return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail";
|
||||
},
|
||||
|
||||
// What sits between the bottom of the felt and the bottom of the window when the
|
||||
// bar is off to one side: the table hint and the row of links under it. Measured,
|
||||
// because both come and go — the hint is empty mid-hand and the links wrap on the
|
||||
// narrowest column.
|
||||
// Taken as the distance from the bottom of the felt to the bottom of the section it
|
||||
// sits in, which is a difference rather than a height — so it doesn't move when the
|
||||
// felt's own height changes, and this can't chase itself around the screen.
|
||||
roomBelowFelt() {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
const section = wrap && wrap.parentElement;
|
||||
if (!section) return 0;
|
||||
const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom;
|
||||
return Math.max(0, Math.round(gap));
|
||||
},
|
||||
|
||||
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
||||
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
||||
// table-sized — so the overhang is a constant rather than something read back off
|
||||
@@ -242,10 +277,29 @@ const ActionBar = {
|
||||
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
|
||||
},
|
||||
|
||||
// Pure, so the wording can be checked without a page to hang it on — the same reason
|
||||
// Render.panelGlances is. A table that has never dealt is on hand one, not hand
|
||||
// zero and not hand undefined, and that fallback is the whole of what there is to
|
||||
// get wrong here.
|
||||
handLabel(state) {
|
||||
return `#${(state && state.handNumber) || 1}`;
|
||||
},
|
||||
|
||||
renderStatus() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionStatus";
|
||||
|
||||
// Which hand this is. It used to be a gold pill beside the page title, and it
|
||||
// was the wrong fact in the wrong place: a counter that changes once a hand,
|
||||
// read after the fact, holding a line of a 390px phone's header all night. It
|
||||
// belongs with the street, the pot and the player count — the other three
|
||||
// things that say what hand you are in — and here it costs nothing, because
|
||||
// this row was already drawn.
|
||||
const hand = document.createElement("span");
|
||||
hand.className = "actionHand";
|
||||
hand.textContent = this.handLabel(this.state);
|
||||
row.appendChild(hand);
|
||||
|
||||
const street = document.createElement("span");
|
||||
street.className = "actionStreet";
|
||||
street.textContent = Betting.streetLabel(this.state.street);
|
||||
@@ -273,8 +327,12 @@ const ActionBar = {
|
||||
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
||||
const runout = document.createElement("span");
|
||||
runout.className = "actionRunout";
|
||||
const stillIn = Betting.liveSeats(this.state).length;
|
||||
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
||||
// It used to end "(12 players)", which is the number the chip immediately to
|
||||
// its left already says, in the same row, at the same moment. Saying it
|
||||
// twice in one breath reads like two different figures being quoted, and on
|
||||
// a 360px phone it cost the sentence a second line — which the felt paid
|
||||
// for, on the one table shape that has twelve seats to fit.
|
||||
runout.textContent = "All-in — running out the board";
|
||||
row.appendChild(runout);
|
||||
}
|
||||
|
||||
@@ -571,10 +629,20 @@ const ActionBar = {
|
||||
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
||||
);
|
||||
|
||||
// Four is what the bar can hold without taking the felt with it — see
|
||||
// buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check
|
||||
// for the ceiling this is keeping the bar under.
|
||||
//
|
||||
// Two, once the run-out has started, for the reason spelled out over there: the
|
||||
// list is decision support, and during a run-out there is no decision left to
|
||||
// support. The felt gets the difference, on the one table shape that has the
|
||||
// most seats to fit into it.
|
||||
return Render.buildPotBreakdown(
|
||||
PotCalculator.layers(contributions),
|
||||
PotCalculator.uncalledReturns(contributions),
|
||||
id => nameById.get(id)
|
||||
id => nameById.get(id),
|
||||
null,
|
||||
{ maxLayers: 4, summarise: Betting.isRunningOut(this.state) }
|
||||
);
|
||||
},
|
||||
|
||||
@@ -969,7 +1037,7 @@ const ActionBar = {
|
||||
if (!field) return;
|
||||
const panel = field.closest("details");
|
||||
if (panel) panel.open = true;
|
||||
field.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
Render.bringIntoView(field, { block: "center" });
|
||||
field.focus();
|
||||
field.select();
|
||||
}));
|
||||
@@ -1100,6 +1168,11 @@ const ActionBar = {
|
||||
// happens, too — say the straddle, then deal.
|
||||
this.straddleButtons(preview).forEach(button => row.appendChild(button));
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
// The first hand is the moment this stops being a setup screen and starts
|
||||
// being a night somebody could lose. It is also a tap, which is what every
|
||||
// browser wants the "keep my data" request to come from — see
|
||||
// requestDurable() in state.js. Harmless and instant after the first time.
|
||||
if (this.hooks.keepStorage) this.hooks.keepStorage();
|
||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
@@ -1129,6 +1202,20 @@ const ActionBar = {
|
||||
return line;
|
||||
},
|
||||
|
||||
// The cards come over before anybody is paid.
|
||||
//
|
||||
// Same shape as the street button above: when the dealer has asked to be asked, the
|
||||
// moment they press this is the moment they are holding the hands, so the sheet
|
||||
// opens here rather than leaving them to find the seat editor for each player with
|
||||
// the pot already on the table. Off by default, and unchanged when it's off.
|
||||
toShowdown() {
|
||||
if (this.state.trackBoard && typeof Render !== "undefined") {
|
||||
Render.openShowdownPicker(() => this.hooks.resolvePot());
|
||||
return;
|
||||
}
|
||||
this.hooks.resolvePot();
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
@@ -1144,7 +1231,7 @@ const ActionBar = {
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||
if (isRiver) {
|
||||
this.hooks.resolvePot();
|
||||
this.toShowdown();
|
||||
return;
|
||||
}
|
||||
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||
@@ -1176,7 +1263,7 @@ const ActionBar = {
|
||||
row.appendChild(this.button(
|
||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||
"actionAdvance",
|
||||
() => this.hooks.resolvePot()
|
||||
() => this.toShowdown()
|
||||
));
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
|
||||
@@ -68,8 +68,20 @@ const CardPicker = {
|
||||
this.state.seats[this.seatIndex].cards[index] = card;
|
||||
},
|
||||
|
||||
close() {
|
||||
// Why the sheet is closing, because one caller needs to tell the difference.
|
||||
//
|
||||
// "filled" is the sheet finishing its own job — the last empty slot it was offering
|
||||
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
|
||||
// A single seat's cards don't care which. A showdown does: it walks the players one
|
||||
// at a time, and the two answers are "next player" and "stop asking".
|
||||
//
|
||||
// The hooks are dropped before the callback runs, so a caller that opens the sheet
|
||||
// again from inside `onDone` installs its own and this close can't fire twice.
|
||||
close(reason = "dismissed") {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
const done = this.hooks && this.hooks.onDone;
|
||||
this.hooks = null;
|
||||
if (done) done(reason);
|
||||
},
|
||||
|
||||
// Every card already dealt somewhere — in front of a player or face up in the
|
||||
@@ -110,7 +122,7 @@ const CardPicker = {
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.close("filled");
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
|
||||
@@ -46,7 +46,10 @@ const Toast = {
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add("toastVisible");
|
||||
this.publishHeight();
|
||||
});
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
@@ -61,6 +64,24 @@ const Toast = {
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
// How much of the bottom of the window this is standing on.
|
||||
//
|
||||
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
|
||||
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
|
||||
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
|
||||
// this to the bar's own height to work out where a focused control has to stop, so
|
||||
// it is published rather than guessed: the toast is one line or two depending on the
|
||||
// message, and on a narrow screen the same message is taller again.
|
||||
publishHeight() {
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
const host = document.getElementById("toastHost");
|
||||
const height = host && host.firstElementChild
|
||||
? Math.round(host.getBoundingClientRect().height)
|
||||
: 0;
|
||||
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// ---- A scroll is not an edit ----
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
|
||||
// wheel drive it. Every other way of driving one is deliberate: the arrow keys, the
|
||||
// little stepper arrows, typing. Scrolling is the one actuation nobody means — the
|
||||
// dealer is reaching for another panel and the cursor happens to be over a field
|
||||
// they touched a moment ago.
|
||||
//
|
||||
// Measured on the running page, at 1280x900 with the sidebar open. Focus the small
|
||||
// blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501.
|
||||
// Both are written straight through to the saved table, because these fields save on
|
||||
// `input` like everything else here. Nothing is announced, nothing looks wrong, and
|
||||
// the tool has invented a chip.
|
||||
//
|
||||
// Blur rather than preventDefault. Cancelling the event would stop the value moving
|
||||
// and stop the page moving with it, which turns a silent bug into a sidebar that
|
||||
// mysteriously will not scroll. Dropping focus lets the scroll do what the dealer
|
||||
// asked for and leaves the number alone — and losing focus on a field you have just
|
||||
// scrolled away from is honest rather than surprising.
|
||||
//
|
||||
// Captured at the document, so it covers the fields the seat editor and the chip
|
||||
// legend build on the fly as well as the ones in the markup.
|
||||
document.addEventListener("wheel", event => {
|
||||
if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur();
|
||||
}, { passive: true });
|
||||
|
||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||
// by reference to every module at mount time, so it has to be refilled in place —
|
||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||
@@ -86,6 +112,54 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
|
||||
// Removing a chip colour, which is only sometimes a small thing.
|
||||
//
|
||||
// A colour nobody has counted is a row in a legend, and taking it out should
|
||||
// cost a click and nothing else. A colour seats are holding is money: the
|
||||
// legend is what prices their chips, so the row leaving takes their chips off
|
||||
// the table with it — and it cannot be put back, because a colour added again
|
||||
// is a new one and the counts belong to the old.
|
||||
//
|
||||
// So the friction is on the second case only. A confirmation that fires every
|
||||
// time is one nobody reads by the third night, and the one that matters here
|
||||
// would go the same way as the four before it that didn't.
|
||||
removeChipColor: async id => {
|
||||
const usage = HoldemState.chipColorUsage(state, id);
|
||||
if (!usage.chip) return;
|
||||
const name = Number(usage.chip.value) > 0
|
||||
? `${Money.format(usage.chip.value)} chip`
|
||||
: "chip colour";
|
||||
|
||||
if (usage.total !== 0) {
|
||||
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
|
||||
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
|
||||
const ok = await Dialog.confirm({
|
||||
// Both directions are named rather than only the loss. Going up is
|
||||
// the odd one — seats falling back to the table's standing ante once
|
||||
// their chips are gone — and a dealer who is told the total will move
|
||||
// and then finds it moved the other way has been told something false.
|
||||
title: usage.total > 0
|
||||
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
|
||||
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
|
||||
message:
|
||||
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
|
||||
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
|
||||
`when the colour goes. Undo will put it back; adding the colour again will not.`,
|
||||
confirmLabel: "Remove it",
|
||||
danger: usage.total > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
runUndoable(
|
||||
usage.total > 0
|
||||
? `${name} removed — ${Money.format(usage.total)} off the table.`
|
||||
: usage.total < 0
|
||||
? `${name} removed — table up ${Money.format(-usage.total)}.`
|
||||
: `${name} removed.`,
|
||||
() => HoldemState.removeChipColor(state, id));
|
||||
},
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
@@ -316,6 +390,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
save: hooks.save,
|
||||
rerender: hooks.rerender,
|
||||
resolvePot: openPotWizard,
|
||||
keepStorage: () => askToKeepStorage(),
|
||||
|
||||
// The first thing a dealer does on a table nobody is sitting at. Opens the
|
||||
// seat editor on the first free chair — the same panel tapping the seat gives
|
||||
@@ -324,7 +399,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
Render.bringIntoView(panel, { block: "nearest" });
|
||||
},
|
||||
|
||||
beginAction: label => {
|
||||
@@ -467,6 +542,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
// The one field on the page that must not go through renderAll: it would write
|
||||
// the textarea's own value back into it on every keystroke and put the caret at
|
||||
// the end. The panel's shut-state summary still has to keep up, though, and it
|
||||
// is the only thing on the page that reads the notes — so it gets called on its
|
||||
// own. It touches the six <span>s in the sidebar headers and nothing else.
|
||||
Render.renderPanelGlances();
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
@@ -616,6 +697,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Focus that lands underneath something fixed is moved out from under it.
|
||||
//
|
||||
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
|
||||
// works when the browser has to scroll to reach the control at all — then the
|
||||
// padding tells it where to stop. A control the browser already considers visible is
|
||||
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
|
||||
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
|
||||
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
|
||||
// nothing, because "nearest" was already satisfied.
|
||||
//
|
||||
// So the overlap is worked out here and the page is scrolled by exactly it. Named
|
||||
// overlays rather than every fixed element on the page, because this runs on every
|
||||
// focus change and walking the whole DOM to find three known things would be work
|
||||
// done ten times a second to reach the same answer. Anything inside one of them is
|
||||
// left alone — the bar's own buttons are meant to be on top of the page.
|
||||
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
|
||||
document.addEventListener("focusin", event => {
|
||||
const el = event.target;
|
||||
if (!el || typeof el.getBoundingClientRect !== "function") return;
|
||||
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width === 0 || box.height === 0) return;
|
||||
|
||||
let lift = 0;
|
||||
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
|
||||
const style = getComputedStyle(node);
|
||||
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
|
||||
if (style.position !== "fixed") return;
|
||||
const over = node.getBoundingClientRect();
|
||||
if (over.width === 0 || over.height === 0) return;
|
||||
// Only what is actually in the way: an overlay off to one side, like the
|
||||
// rail on a phone held sideways, is not covering a control in the middle.
|
||||
if (box.left >= over.right || box.right <= over.left) return;
|
||||
if (box.bottom <= over.top || box.top >= over.bottom) return;
|
||||
lift = Math.max(lift, box.bottom - over.top + 8);
|
||||
});
|
||||
|
||||
// Instant rather than smooth. This is the page getting out of the way of
|
||||
// something the dealer has already arrived at, and animating it would put the
|
||||
// control they are typing into in motion under them.
|
||||
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
|
||||
|
||||
// Scrolling is not always enough. The page only has as much room below its
|
||||
// content as the padding under the app gives it, and that is sized for the bar
|
||||
// — the toast stands on top of the bar, so a control at the very end of the page
|
||||
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
|
||||
// lift wanted, 26px of page to do it in.
|
||||
//
|
||||
// Sizing the padding for the toast as well would fix it by making the page grow
|
||||
// and shrink by 64px every time anything is announced, which is a worse thing to
|
||||
// do to a dealer than the fault. So the toast gives way instead: it is an
|
||||
// eight-second message about something that has already happened, and it is
|
||||
// standing on a control somebody has just navigated to. Only after the scroll
|
||||
// has been tried, so a toast that is not in the way is left alone.
|
||||
const toast = document.querySelector("#toastHost .toast");
|
||||
if (!toast) return;
|
||||
const after = el.getBoundingClientRect();
|
||||
const over = toast.getBoundingClientRect();
|
||||
const stillCovered = after.left < over.right && after.right > over.left &&
|
||||
after.bottom > over.top && after.top < over.bottom;
|
||||
if (stillCovered) Toast.dismiss();
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
@@ -651,6 +796,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// The two ways out of the sheet that every other overlay in the tool already had.
|
||||
//
|
||||
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
|
||||
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
|
||||
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
|
||||
// So the two behaviours it was missing are wired here against the same state the
|
||||
// scrim is drawn from.
|
||||
document.getElementById("seatSheetScrim")
|
||||
.addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// Not in capture, and behind Modal's own check: a card picker opened *from* the
|
||||
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
|
||||
// the seat away". Modal stops that event before this listener ever sees it; the
|
||||
// guard is for the case where it hasn't, and costs nothing.
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (Modal.open.length > 0) return;
|
||||
if (!document.body.classList.contains("seatSheetOpen")) return;
|
||||
Render.closeSeatEditor();
|
||||
});
|
||||
|
||||
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
|
||||
document.getElementById("addChipsOverlay").addEventListener("click", e => {
|
||||
@@ -733,7 +899,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// costs a screen reader the sentence.
|
||||
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||
jump.addEventListener("click", () => {
|
||||
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
Render.bringIntoView(section, { block: "start" });
|
||||
// The heading is what the dealer is now reading, so that is where the
|
||||
// keyboard and a screen reader should be too.
|
||||
heading.setAttribute("tabindex", "-1");
|
||||
@@ -883,6 +1049,74 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
{ duration: 12000 });
|
||||
}
|
||||
|
||||
// ---- Keeping the screen on ----
|
||||
//
|
||||
// The lock itself is taken and dropped in renderAll, because that is the one place
|
||||
// every start and end of a hand already passes through. All that lives here is the
|
||||
// dealer's say in it: the toggle, and the way out offered by the toast the first
|
||||
// time it takes hold.
|
||||
WakeLock.hooks = {
|
||||
turnOff: () => {
|
||||
state.keepScreenAwake = false;
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
|
||||
},
|
||||
};
|
||||
WakeLock.start(() => state);
|
||||
|
||||
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
|
||||
state.keepScreenAwake = e.target.checked;
|
||||
hooks.save(state);
|
||||
// renderAll does the acquiring and releasing, so turning this off mid-hand
|
||||
// drops the lock in the same tick rather than at the end of the hand.
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// ---- Asking the browser to keep the night ----
|
||||
//
|
||||
// Reported rather than announced. A dealer opening Save & reset is already thinking
|
||||
// about losing the table, and that is the only moment this is worth a sentence.
|
||||
const showStorageDurability = async () => {
|
||||
const hint = document.getElementById("storageDurabilityHint");
|
||||
const button = document.getElementById("keepStorageBtn");
|
||||
if (!hint || !button) return;
|
||||
|
||||
if (!HoldemState.supportsDurableStorage()) {
|
||||
// Nothing to say and nothing to offer. A browser this old still keeps the
|
||||
// table exactly as well as it did yesterday.
|
||||
hint.hidden = true;
|
||||
button.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const durable = await HoldemState.isDurable();
|
||||
hint.hidden = false;
|
||||
hint.textContent = durable
|
||||
? "This browser has promised to keep it: the table survives the phone running short on space."
|
||||
: "This browser may clear the table on its own if the phone runs short on space.";
|
||||
button.hidden = durable;
|
||||
};
|
||||
|
||||
// Fires on the first deal of every session, which costs nothing after the first
|
||||
// time it is granted — persisted() is checked before persist() is asked for.
|
||||
const askToKeepStorage = async () => {
|
||||
await HoldemState.requestDurable();
|
||||
showStorageDurability();
|
||||
};
|
||||
|
||||
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
|
||||
const granted = await HoldemState.requestDurable();
|
||||
showStorageDurability();
|
||||
// Said either way, because a button that sometimes does nothing visible is a
|
||||
// button a dealer presses four more times.
|
||||
Toast.show(granted
|
||||
? "This browser will keep the table now."
|
||||
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
|
||||
});
|
||||
|
||||
showStorageDurability();
|
||||
|
||||
// ---- Working with no signal ----
|
||||
//
|
||||
// The night already survives anything — it lives in localStorage — but the page
|
||||
@@ -896,33 +1130,78 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// cannot drift from the markup, which is the way a hand-kept one always fails: it
|
||||
// stays right until somebody adds a script tag, and then the tool is quietly
|
||||
// offline-broken until a dealer finds out the hard way.
|
||||
const registerWorker = () => {
|
||||
const MANIFEST_URL = "/casino/holdem.webmanifest";
|
||||
|
||||
// The icons are the one part of the shell the document doesn't mention: the browser
|
||||
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
|
||||
// at them and the sweep above misses all four. An installed app that was launched
|
||||
// with no signal then had no icon to show for itself.
|
||||
//
|
||||
// Read out of the manifest rather than typed out here, for the same reason the rest
|
||||
// of the list is read out of the document — a copy of a list is a list that is one
|
||||
// edit away from being wrong, and this particular wrongness is invisible until
|
||||
// somebody is standing in a back room with no signal.
|
||||
const manifestIcons = async () => {
|
||||
try {
|
||||
// The cache before the network, which is backwards from how the rest of this
|
||||
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
|
||||
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
|
||||
// every single launch — noise on the one screen where a real error needs to
|
||||
// be visible, in service of re-reading a list of four icon paths that has not
|
||||
// changed since the manifest was written and is already cached anyway.
|
||||
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|
||||
|| await fetch(MANIFEST_URL);
|
||||
if (!res || !res.ok) return [];
|
||||
const manifest = await res.json();
|
||||
return (manifest.icons || [])
|
||||
.map(icon => icon && icon.src)
|
||||
.filter(Boolean)
|
||||
.map(src => new URL(src, location.origin).href);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const registerWorker = async () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const shell = [
|
||||
location.pathname,
|
||||
"/casino/holdem.webmanifest",
|
||||
...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href),
|
||||
MANIFEST_URL,
|
||||
...await manifestIcons(),
|
||||
// Stylesheets, plus the icons the document points at itself — iOS reads
|
||||
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
|
||||
// an installed iPhone launched with no signal needs this one specifically.
|
||||
...[...document.querySelectorAll(
|
||||
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][href]'
|
||||
)].map(el => el.href),
|
||||
...[...document.querySelectorAll("script[src]")].map(el => el.src),
|
||||
].filter(url => new URL(url, location.origin).origin === location.origin);
|
||||
|
||||
navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" })
|
||||
.then(reg => {
|
||||
// The worker that will answer for this page — `controller` is still null
|
||||
// on the very first visit, before activate has claimed it.
|
||||
const worker = reg.active || navigator.serviceWorker.controller;
|
||||
if (worker) {
|
||||
worker.postMessage({ type: "precache", urls: shell });
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker.ready.then(ready => {
|
||||
if (ready.active) ready.active.postMessage({ type: "precache", urls: shell });
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
|
||||
|
||||
// Whichever worker this registration has, at whatever stage it has reached.
|
||||
//
|
||||
// This used to fall back on navigator.serviceWorker.ready, which resolves
|
||||
// with the registration *controlling this page* — and so never resolves at
|
||||
// all on a page outside the worker's scope. That is not hypothetical: it is
|
||||
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
|
||||
// and the symptom was the worst kind. The registration succeeded, the worker
|
||||
// activated, nothing was logged, and the cache stayed completely empty — a
|
||||
// dealer who had "loaded the tool for tonight" had loaded nothing.
|
||||
const worker = reg.installing || reg.waiting || reg.active;
|
||||
if (!worker) return;
|
||||
|
||||
const send = () => worker.postMessage({ type: "precache", urls: shell });
|
||||
if (worker.state === "activated") send();
|
||||
else worker.addEventListener("statechange", () => {
|
||||
if (worker.state === "activated") send();
|
||||
});
|
||||
} catch {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
}
|
||||
};
|
||||
|
||||
// After load, not during it: registering competes with the page's own scripts for
|
||||
|
||||
@@ -156,6 +156,29 @@ const Money = {
|
||||
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||||
},
|
||||
|
||||
// Would a wheel over this field change what it says?
|
||||
//
|
||||
// True only for a focused `<input type="number">`, which a browser treats as a
|
||||
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
|
||||
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
|
||||
// Scrolling is the one nobody means: the dealer is reaching for another panel and
|
||||
// the cursor happens to still be over a field they touched a moment ago.
|
||||
//
|
||||
// A predicate rather than a listener so the rule can be tested without a browser,
|
||||
// the same way selectsOnFocus is. main.js does the blurring.
|
||||
wheelWouldEdit(field, activeElement) {
|
||||
// The tag check is belt and braces and known to be so: nothing but an <input>
|
||||
// carries type="number", so the line below already excludes every textarea,
|
||||
// select and button on the page. Deleting it passes every test in this file,
|
||||
// which was checked rather than assumed. It stays for symmetry with
|
||||
// selectsOnFocus above, not because it is load-bearing.
|
||||
if (!field || field.tagName !== "INPUT") return false;
|
||||
if (String(field.type || "").toLowerCase() !== "number") return false;
|
||||
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
|
||||
// blurring something that isn't focused would be a no-op with a side effect.
|
||||
return activeElement === field;
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
// glance, and the sign belongs out front where the eye lands first.
|
||||
formatSigned(amount) {
|
||||
|
||||
@@ -20,9 +20,46 @@ const Render = {
|
||||
// last one still applies.
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.watchSeatSheetHeight();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
// How tall the seat sheet is, published the way the action bar publishes its own.
|
||||
//
|
||||
// Two things are fixed to the bottom of a phone here, and the toast is the one that
|
||||
// has to give way. It already lifts clear of the action bar — `body.hasActionBar
|
||||
// #toastHost` — because a toast sitting on Fold is a toast the dealer taps by
|
||||
// accident. The sheet is the same situation and had never been told about: opening
|
||||
// a seat put a "Keeping the screen on" toast squarely over the sheet's own rows,
|
||||
// with its Undo and its × live above them.
|
||||
//
|
||||
// Measured rather than guessed, for the same reason the bar's is: the height moves
|
||||
// when a section is unfolded, so any number written down here would be wrong as
|
||||
// soon as somebody tapped "What they've put in".
|
||||
watchSeatSheetHeight() {
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
if (!panel || this.seatSheetWatcher) return;
|
||||
|
||||
const publish = () => {
|
||||
// Only when it is actually a sheet over the bottom of the screen. Above
|
||||
// 700px this is a panel in the sidebar column, and its height is nothing to
|
||||
// do with how much of the bottom edge is spoken for.
|
||||
const isSheet = getComputedStyle(panel).position === "fixed";
|
||||
const open = document.body.classList.contains("seatSheetOpen");
|
||||
const height = isSheet && open ? panel.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--seatSheetHeight", `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
this.publishSeatSheetHeight = publish;
|
||||
if (typeof ResizeObserver === "function") {
|
||||
this.seatSheetWatcher = new ResizeObserver(publish);
|
||||
this.seatSheetWatcher.observe(panel);
|
||||
}
|
||||
window.addEventListener("resize", publish);
|
||||
window.addEventListener("orientationchange", publish);
|
||||
publish();
|
||||
},
|
||||
|
||||
renderAll() {
|
||||
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
|
||||
this.selectedSeatIndex = null;
|
||||
@@ -34,11 +71,46 @@ const Render = {
|
||||
this.renderNotes();
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderPanelGlances();
|
||||
this.renderHeaderActions();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
// Last, and from here rather than from the dozen places a hand can start or
|
||||
// end. Every one of those already ends in a renderAll, and a wake lock that is
|
||||
// taken in some of them and not others is worse than none: the dealer learns
|
||||
// the screen stays on, and then one night it doesn't.
|
||||
if (typeof WakeLock !== "undefined") WakeLock.sync(this.state);
|
||||
},
|
||||
|
||||
// ---- Bringing something into view, at the speed the reader asked for ----
|
||||
//
|
||||
// Three places scroll something into view — the raise pad's amount field, the seat
|
||||
// editor sheet, and the sidebar section a shortcut jumps to — and all three asked
|
||||
// for "smooth" unconditionally.
|
||||
//
|
||||
// A smooth scroll is the most provocative motion a page can make for somebody with a
|
||||
// vestibular disorder, because what moves is the whole viewport rather than one
|
||||
// control. The stylesheet has honoured `prefers-reduced-motion` for a while; the
|
||||
// scripts were never asked to, and between them they held the three largest
|
||||
// movements in the tool.
|
||||
//
|
||||
// Read per call rather than cached. The setting can change while the page is open,
|
||||
// and a dealer who turns it on mid-session should not have to reload the table to be
|
||||
// taken seriously.
|
||||
prefersReducedMotion() {
|
||||
return typeof matchMedia === "function"
|
||||
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
},
|
||||
|
||||
// Same arguments as scrollIntoView, minus the one the caller shouldn't be choosing.
|
||||
bringIntoView(el, options = {}) {
|
||||
if (!el || typeof el.scrollIntoView !== "function") return;
|
||||
el.scrollIntoView({
|
||||
...options,
|
||||
behavior: this.prefersReducedMotion() ? "auto" : "smooth",
|
||||
});
|
||||
},
|
||||
|
||||
renderSetupFields() {
|
||||
@@ -47,6 +119,7 @@ const Render = {
|
||||
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
|
||||
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
|
||||
document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
|
||||
document.getElementById("keepScreenAwakeToggle").checked = this.state.keepScreenAwake !== false;
|
||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
@@ -65,6 +138,105 @@ const Render = {
|
||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
||||
},
|
||||
|
||||
// ---- What a shut panel says about itself ----
|
||||
//
|
||||
// The sidebar is eight <details>, and every one of them was exactly 60px tall with
|
||||
// nothing in it but its own name: 480px of a phone's screen, and 480px of a 660px
|
||||
// desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box
|
||||
// balances, whether anyone is waiting for a seat, how many hands have been played —
|
||||
// the page has all three worked out already, and every one of them cost a tap to
|
||||
// find out, on a row two thirds of which was empty.
|
||||
//
|
||||
// That is the failure mode the disclosure pattern is warned about by name: a header
|
||||
// you have to open to learn whether it was worth opening. Salesforce and Carbon both
|
||||
// answer it the same way — put the status in the header — and NN/g's objection to
|
||||
// accordions is exactly the cost of the taps this saves.
|
||||
//
|
||||
// So a shut panel now carries the one fact you would have opened it for. The panel
|
||||
// itself still holds the sentence: "Off by $40" is the alarm, and which seat is
|
||||
// holding chips nobody wrote down is a paragraph, which is the half that belongs
|
||||
// behind the tap.
|
||||
//
|
||||
// Silence is a state too. A glance appears when it has something a dealer would act
|
||||
// on and stays away otherwise — nobody waiting, no notes, no hands yet — because a
|
||||
// row reading "Nobody waiting" all night is the clutter this is here to remove, and
|
||||
// eight rows that always say something are eight rows that say nothing again.
|
||||
PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting",
|
||||
"glanceHands", "glanceChips", "glanceNotes"],
|
||||
|
||||
// Pure, so the wording can be tested without a page to hang it on. Every value is
|
||||
// read off the state the panel below it renders from, never off the panel's own
|
||||
// DOM — a shut <details> has no rendered body to read.
|
||||
panelGlances(state) {
|
||||
const glance = (text, tone = "") => ({ text: text || "", tone });
|
||||
if (!state) return {};
|
||||
|
||||
const blinds = state.smallBlind > 0 && state.bigBlind > 0
|
||||
? ` · ${Money.format(state.smallBlind)}/${Money.format(state.bigBlind)}`
|
||||
: "";
|
||||
const waiting = (state.waitingList || []).length;
|
||||
const hands = (state.handHistory || []).length;
|
||||
|
||||
// Denominations rather than a count of rows, because "are the values right for
|
||||
// tonight's set" is the question, and five colours is the answer to a different
|
||||
// one. Chips with no value on them aren't in the range: a blank row is a chip
|
||||
// the dealer hasn't got to yet, not a zero-dollar chip.
|
||||
const values = (state.chipLegend || [])
|
||||
.map(chip => Number(chip.value) || 0).filter(v => v > 0).sort((a, b) => a - b);
|
||||
const span = values.length
|
||||
? (values[0] === values[values.length - 1]
|
||||
? Money.format(values[0])
|
||||
: `${Money.format(values[0])}–${Money.format(values[values.length - 1])}`)
|
||||
: "";
|
||||
const chips = values.length
|
||||
? `${values.length} ${values.length === 1 ? "colour" : "colours"} · ${span}`
|
||||
: "";
|
||||
|
||||
// The first line that has anything on it. Notes get typed as a list — "Sam owes
|
||||
// Dee 20" under a blank line under a heading — and the first line of the string
|
||||
// is often not the first line of the note.
|
||||
const notes = (state.notes || "").split("\n")
|
||||
.map(line => line.trim()).find(Boolean) || "";
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
// Before anybody has bought in there is nothing to reconcile, and the panel
|
||||
// hides its own check for the same reason — so the glance saying "Balanced"
|
||||
// over an empty table would be answering for a count that was never taken.
|
||||
const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0;
|
||||
|
||||
return {
|
||||
glanceTableSetup: glance(`${state.seatCount} seats${blinds}`),
|
||||
glanceStandings: nothingBanked ? glance("")
|
||||
: bank.balanced ? glance("Balanced", "ok")
|
||||
: glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"),
|
||||
glanceWaiting: glance(waiting ? `${waiting} waiting` : ""),
|
||||
glanceHands: glance(hands ? `${hands} ${hands === 1 ? "hand" : "hands"}` : ""),
|
||||
glanceChips: glance(chips),
|
||||
glanceNotes: glance(notes),
|
||||
};
|
||||
},
|
||||
|
||||
renderPanelGlances() {
|
||||
const glances = this.panelGlances(this.state);
|
||||
// renderAll runs after every tap a dealer makes and none of this changes between
|
||||
// most of them. Writing textContent is cheap; making the browser re-style eight
|
||||
// rows for the same eight strings is the part worth not doing.
|
||||
const signature = this.PANEL_GLANCES
|
||||
.map(id => `${id}:${(glances[id] || {}).text}|${(glances[id] || {}).tone}`).join(";");
|
||||
if (signature === this.lastGlanceSignature) return;
|
||||
this.lastGlanceSignature = signature;
|
||||
|
||||
this.PANEL_GLANCES.forEach(id => {
|
||||
const host = document.getElementById(id);
|
||||
if (!host) return;
|
||||
const { text, tone } = glances[id] || { text: "", tone: "" };
|
||||
host.hidden = !text;
|
||||
host.textContent = text;
|
||||
host.classList.toggle("panelGlanceOk", tone === "ok");
|
||||
host.classList.toggle("panelGlanceWarn", tone === "warn");
|
||||
});
|
||||
},
|
||||
|
||||
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
|
||||
// is nothing to take back, and afterwards the header would be offering to unwind a
|
||||
// hand that has already been paid — so the button comes and goes with the hand
|
||||
@@ -75,10 +247,18 @@ const Render = {
|
||||
misdeal.hidden = !this.state.bettingOpen;
|
||||
|
||||
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
|
||||
// Pot has no pot — but Resolve Pot is the accent green, so the loudest control
|
||||
// on a new dealer's first screen was the one that could do the least. It is
|
||||
// now visibly not-yet, which puts the only green left on the felt where the
|
||||
// actual next step is.
|
||||
// Pot has no pot, so both are visibly not-yet rather than sitting there looking
|
||||
// ready.
|
||||
//
|
||||
// This used to be half of a colour argument as well — Resolve Pot was the accent
|
||||
// green, so the loudest control on a new dealer's first screen was the one that
|
||||
// could do the least. The disabling fixed that for the empty table and left it
|
||||
// everywhere else: measured over a whole hand, the header was green at all
|
||||
// seventeen steps, including the four where the bar held the only button there
|
||||
// was to press. The accent has since gone to the bar's own next-step button,
|
||||
// where it belongs at every stage rather than at one of them, so what is left
|
||||
// here is only the part about what a control can do — which is still worth
|
||||
// saying, because a shortcut that cannot work yet should not look ready.
|
||||
//
|
||||
// Disabled rather than hidden: hiding them would move the header the moment
|
||||
// the first player is named, and a control that vanishes teaches nothing about
|
||||
@@ -405,6 +585,43 @@ const Render = {
|
||||
// The step the search walks down in. Six pixels is finer than the difference between
|
||||
// two font sizes and coarse enough to settle in ten tries.
|
||||
SEAT_WIDTH_STEP: 6,
|
||||
// And the ceiling when the felt has room to spare, which on anything tablet-sized or
|
||||
// larger it does. 96px was the only ceiling there was, so the felt grew with the
|
||||
// window and the seats did not: measured nine-handed, the pucks covered 19% of the
|
||||
// felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond, with the
|
||||
// stack figure a dealer reads sitting at 10px on a 2560px monitor.
|
||||
//
|
||||
// 144 rather than further. Past it the extra width buys nothing a dealer can use —
|
||||
// at 144 a 15px "Mike from work" is already on one line, which is the longest thing
|
||||
// a name is asked to be — and a puck much wider than its own contents stops reading
|
||||
// as a seat and starts reading as a card.
|
||||
SEAT_WIDTH_ROOMY_MAX: 144,
|
||||
// Coarser above the ordinary ceiling, because up there the difference is 8% of the
|
||||
// puck rather than the difference between two font sizes, and every candidate width
|
||||
// costs a forced layout.
|
||||
SEAT_WIDTH_ROOMY_STEP: 12,
|
||||
// And the puck may not be more than a fifth of the shorter span of the ring.
|
||||
//
|
||||
// The containment arithmetic keeps a puck inside the table's *box*; the rail it
|
||||
// looks like it should stay inside is an ellipse, and at the top and bottom of the
|
||||
// ring the ellipse curves away from the box. Seats have always sat a little over
|
||||
// the rail because of it — measured before any of this, 10px on a desktop and 46px
|
||||
// on a phone held sideways — and the wider the puck, the wider that gap opens.
|
||||
//
|
||||
// Which made the first version of this a bad trade on exactly the screens it was
|
||||
// supposed to leave alone. A 144px puck on an iPhone's 278px ring is half the width
|
||||
// of the table: six-handed, all six sat over the rail by 15px against 5px before,
|
||||
// and it read as a stack of cards rather than a table with people around it.
|
||||
//
|
||||
// A fifth is measured, not chosen. The shorter span is 248-318px on every phone in
|
||||
// the sweep including sideways, and 672-978px on tablets and desktops, so any
|
||||
// divisor between about 3.3 and 7 splits them; five sits in the middle with room
|
||||
// either side. Above it the pass is skipped and a phone measures exactly the ten
|
||||
// widths, and settles on exactly the puck, that it did before.
|
||||
SEAT_ROOMY_SPAN_RATIO: 5,
|
||||
// A puck past the ordinary ceiling gets the larger type, which is the point of the
|
||||
// extra width — see `.seatsRoomy` in holdem.css.
|
||||
SEAT_ROOMY_FROM_PX: 108,
|
||||
// Below this a puck cannot hold two lines of name and a row of hole cards, so the
|
||||
// typography drops to the tight set in one go. Width scales continuously; type
|
||||
// changes deliberately, which is the split the modern guidance actually draws —
|
||||
@@ -418,7 +635,7 @@ const Render = {
|
||||
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
|
||||
// takes no room between two seats; what it must not do is reach *sideways*, which is
|
||||
// what seatFootprint.test.js holds the stylesheet to.
|
||||
SEAT_BADGE_OVERHANG_PX: 9,
|
||||
SEAT_BADGE_OVERHANG_PX: 5,
|
||||
|
||||
// How much space two neighbouring seats need between their centres.
|
||||
//
|
||||
@@ -497,30 +714,77 @@ const Render = {
|
||||
// left corner onto whatever was beside it, and that is fixed where it was caused —
|
||||
// in the stylesheet, by moving the badges inside the puck's own width.
|
||||
//
|
||||
// Its remaining overhang is vertical and costs nothing, because it always points
|
||||
// *away* from the table: `top: -9px` on the upper arc, `bottom: -9px` on the lower
|
||||
// one, so two seats facing each other across the felt wear their badges back to
|
||||
// back. Two seats on the same arc wear them at the same height, offset by exactly
|
||||
// as much as the pucks are.
|
||||
// Its remaining overhang is vertical: `top: -9px` on the upper arc, `bottom: -9px`
|
||||
// on the lower one, so it always points away from the table and two seats facing
|
||||
// each other across the felt wear their badges back to back.
|
||||
//
|
||||
// Adding it anyway was tried first, and it broke the case it was least expected to:
|
||||
// a phone held sideways gives the ring 87px of height, two rows of 55px pucks leave
|
||||
// 18px between them, and demanding 18px more for badges that point outward made a
|
||||
// six-handed landscape table unsatisfiable at any width. Twelve-seat tables on three
|
||||
// phones went the same way. The clearance below is what keeps rounded corners from
|
||||
// reading as a touch.
|
||||
// That is why it was left out of this for a while, and the reasoning has one hole
|
||||
// in it. "Two seats on the same arc wear them at the same height" is true of a
|
||||
// straight rail and false of a ring: neighbours on a curve sit at different heights,
|
||||
// so the badge that points away from the table on one of them points at the corner
|
||||
// of the other. Measured, three tables in the sweep had a player's stack painted
|
||||
// under the next player's SB or BB — the badge outranks everything on the felt, so
|
||||
// what a dealer reads there is the wrong number entirely.
|
||||
//
|
||||
// Counted once, not twice. Both boxes here are the same size, so the separation
|
||||
// seatsCollide asks for is one box plus this — which is exactly right for two seats
|
||||
// each wearing a badge on the same side, and generous for the far commoner case
|
||||
// where only one of them is in the blinds at all.
|
||||
//
|
||||
// Adding it was tried once before and abandoned, because a phone held sideways gave
|
||||
// the ring 87px of height and two rows of 55px pucks had 18px between them: nine
|
||||
// more was unsatisfiable at any width. That felt is 205px tall now — see the rail in
|
||||
// holdem.css — and the shortage that made this impossible is the one that block went
|
||||
// and fixed.
|
||||
seatFootprint(puckBox) {
|
||||
return puckBox && { width: puckBox.width, height: puckBox.height };
|
||||
return puckBox && {
|
||||
width: puckBox.width,
|
||||
height: puckBox.height + this.SEAT_BADGE_OVERHANG_PX,
|
||||
};
|
||||
},
|
||||
|
||||
applySeatWidth(layer, width) {
|
||||
layer.style.setProperty("--seatPuckW", `${width}px`);
|
||||
layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX);
|
||||
layer.classList.toggle("seatsRoomy", width >= this.SEAT_ROOMY_FROM_PX);
|
||||
},
|
||||
|
||||
// Is it worth asking about pucks wider than the ordinary ceiling?
|
||||
//
|
||||
// Arithmetic, not a measurement, and that is the whole point: closestSeatGapPx is
|
||||
// the tightest centre-to-centre distance the seats will sit at on the widest ring
|
||||
// they could have, so if that already clears the widest ordinary puck and its
|
||||
// clearance, the felt has room going spare and the coarse pass above the ceiling
|
||||
// will find some of it. If it does not — every phone in the sweep, at every seat
|
||||
// count — the pass is skipped and exactly the ten widths that were measured before
|
||||
// are measured now.
|
||||
//
|
||||
// Worth being strict about, because the candidates are not free. Raising the ceiling
|
||||
// for everybody took the layout pass from 24ms to 73ms on a twelve-handed desktop,
|
||||
// and it is the phones, which would gain nothing at all, that can least afford it.
|
||||
roomForWiderPucks(count, box) {
|
||||
if (!box) return false;
|
||||
if (this.roomyWidthCeiling(box) <= this.SEAT_WIDTH_MAX) return false;
|
||||
const gap = this.closestSeatGapPx(count, box, this.seatRingRadii());
|
||||
return gap > this.SEAT_WIDTH_MAX + this.SEAT_CLEARANCE_PX;
|
||||
},
|
||||
|
||||
// The widest a puck may be drawn on this felt, whatever the seats would allow.
|
||||
roomyWidthCeiling(box) {
|
||||
if (!box || !box.width || !box.height) return this.SEAT_WIDTH_MAX;
|
||||
const span = Math.min(box.width, box.height) / this.SEAT_ROOMY_SPAN_RATIO;
|
||||
return Math.min(this.SEAT_WIDTH_ROOMY_MAX, Math.floor(span));
|
||||
},
|
||||
|
||||
// Widest first, because the widest that fits is the one we want.
|
||||
seatWidthCandidates() {
|
||||
seatWidthCandidates(count, box) {
|
||||
const widths = [];
|
||||
if (count != null && this.roomForWiderPucks(count, box)) {
|
||||
const ceiling = this.roomyWidthCeiling(box);
|
||||
for (let w = ceiling; w > this.SEAT_WIDTH_MAX; w -= this.SEAT_WIDTH_ROOMY_STEP) {
|
||||
widths.push(w);
|
||||
}
|
||||
}
|
||||
for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) {
|
||||
widths.push(w);
|
||||
}
|
||||
@@ -531,9 +795,26 @@ const Render = {
|
||||
// What the table has changed about the shape of its pucks. Anything that can make a
|
||||
// name wrap to a second line belongs here, because the height that comes back from
|
||||
// measuring is what the ring is sized against.
|
||||
//
|
||||
// The hole-card line and the amount pill belong here for the same reason, now that
|
||||
// an empty one collapses instead of holding its space: a puck with either of them
|
||||
// filled is about 15px taller than the same puck without, and a ring still sized
|
||||
// from the measurement taken before the first chips went in is sized for a puck
|
||||
// that no longer exists.
|
||||
//
|
||||
// Booleans over the whole table rather than a list per seat, because that is what
|
||||
// the measurement is — measuredSeatBox takes the tallest puck on the ring, so what
|
||||
// matters is whether *anybody* has one. A boolean also flips about twice a hand
|
||||
// rather than on every action, and each flip costs ten forced layouts, which is the
|
||||
// expense this cache exists to avoid.
|
||||
seatShapeSignature() {
|
||||
if (!this.state) return "";
|
||||
return `${this.state.seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}`;
|
||||
const seats = this.state.seats;
|
||||
const anyCards = seats.some(p => (p.cards || []).some(Boolean));
|
||||
const anyAmount = seats.some(p => Betting.handTotal(p) > 0 ||
|
||||
HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0);
|
||||
return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` +
|
||||
`|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`;
|
||||
},
|
||||
|
||||
// Which width the pucks get, and how wide a ring they sit on.
|
||||
@@ -586,7 +867,7 @@ const Render = {
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const width of this.seatWidthCandidates()) {
|
||||
for (const width of this.seatWidthCandidates(count, box)) {
|
||||
this.applySeatWidth(layer, width);
|
||||
const puck = this.measuredSeatBox();
|
||||
if (!puck) {
|
||||
@@ -1135,19 +1416,7 @@ const Render = {
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = layer.children[index];
|
||||
if (!node) return;
|
||||
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||
node.style.left = `${pos.left}%`;
|
||||
node.style.top = `${pos.top}%`;
|
||||
|
||||
// Which side of the seat the pot is on, which is the side its bet chip
|
||||
// arrives from — the chip sits on the same spoke, closer in. For a seat on
|
||||
// the top of the ring that's from below; on the bottom, from above.
|
||||
//
|
||||
// The blind badge has to be on the other side or the chip lands on top of
|
||||
// it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
|
||||
// the badge's 288 square pixels, and it sits a layer higher, so the marker
|
||||
// telling the dealer who the big blind is was simply gone.
|
||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
||||
this.placeSeat(node, index, aspect, radii);
|
||||
|
||||
// The chair number is set once, in buildSeatNodes — a seat's index is the
|
||||
// one thing about it that cannot change while the node exists.
|
||||
@@ -1200,10 +1469,169 @@ const Render = {
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
});
|
||||
|
||||
this.fitSeatNames(layer);
|
||||
// The names have just changed how tall some of these pucks are, so this comes
|
||||
// after them: a two-line name shortened to one line is 15px of height back.
|
||||
const settled = this.settleSeatsOnTable(layer, aspect, radii);
|
||||
|
||||
// Kept so the board can ask for the chips to be laid out again if placing it
|
||||
// changes what is in the middle of the felt — see placeBoard().
|
||||
this.lastBetLayout = { aspect, radii };
|
||||
this.renderBets(aspect, radii);
|
||||
this.lastBetLayout = { aspect, radii: settled };
|
||||
this.renderBets(aspect, settled);
|
||||
},
|
||||
|
||||
// One seat, onto the ring.
|
||||
placeSeat(node, index, aspect, radii) {
|
||||
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||
node.style.left = `${pos.left}%`;
|
||||
node.style.top = `${pos.top}%`;
|
||||
|
||||
// Which side of the seat the pot is on, which is the side its bet chip arrives
|
||||
// from — the chip sits on the same spoke, closer in. For a seat on the top of
|
||||
// the ring that's from below; on the bottom, from above.
|
||||
//
|
||||
// The blind badge has to be on the other side or the chip lands on top of it.
|
||||
// Measured on a 390px phone: the big blind's own $2 chip covered 196 of the
|
||||
// badge's 288 square pixels, and it sits a layer higher, so the marker telling
|
||||
// the dealer who the big blind is was simply gone.
|
||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
||||
},
|
||||
|
||||
// Every seat on the table, checked against the table rather than predicted onto it.
|
||||
//
|
||||
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
|
||||
// the rail, and it is right about that — but it is right about a puck it measured
|
||||
// before the ring moved, before the width search settled, and before a name was
|
||||
// shortened or wrapped to a second line. Every one of those changes a puck's height
|
||||
// by up to 15px after the arithmetic is done. Measured on the running page, four of
|
||||
// the thirteen viewports finished with the top seat 5-6px past the rail: not a wrong
|
||||
// formula, a stale input to a right one.
|
||||
//
|
||||
// So the ring is settled the way the layout audit judges it — by looking at where
|
||||
// the pucks actually landed. If the worst of them hangs 6px over, the ring comes in
|
||||
// by 6px and they are placed again. Two passes, because the first correction is
|
||||
// exact and the second only exists in case moving them changed a height again.
|
||||
//
|
||||
// Never past RING_MIN_R. Below that the ring has collapsed into the pot, which is a
|
||||
// worse table than one with a puck touching the rail — and a felt that small is the
|
||||
// case the floor was put there for.
|
||||
SEAT_SETTLE_PASSES: 2,
|
||||
// The audit's own slack. Anything under a pixel is a rounding difference between
|
||||
// two ways of measuring the same edge, and chasing it would move the ring forever.
|
||||
SEAT_SETTLE_SLACK_PX: 1,
|
||||
|
||||
settleSeatsOnTable(layer, aspect, radii) {
|
||||
const table = typeof document !== "undefined" && document.getElementById("pokerTable");
|
||||
const box = this.ringBox();
|
||||
if (!table || !box || !box.width || !box.height) return radii;
|
||||
|
||||
let current = radii;
|
||||
for (let pass = 0; pass < this.SEAT_SETTLE_PASSES; pass++) {
|
||||
const felt = table.getBoundingClientRect();
|
||||
if (!felt.width || !felt.height) return current;
|
||||
|
||||
let overX = 0;
|
||||
let overY = 0;
|
||||
for (let index = 0; index < layer.childElementCount; index++) {
|
||||
const seat = layer.children[index].getBoundingClientRect();
|
||||
if (!seat.width || !seat.height) continue;
|
||||
overX = Math.max(overX, felt.left - seat.left, seat.right - felt.right);
|
||||
overY = Math.max(overY, felt.top - seat.top, seat.bottom - felt.bottom);
|
||||
}
|
||||
if (overX <= this.SEAT_SETTLE_SLACK_PX && overY <= this.SEAT_SETTLE_SLACK_PX) return current;
|
||||
|
||||
const next = {
|
||||
rx: Math.max(this.RING_MIN_R, current.rx - (Math.max(0, overX) / box.width) * 100),
|
||||
ry: Math.max(this.RING_MIN_R, current.ry - (Math.max(0, overY) / box.height) * 100),
|
||||
};
|
||||
// The floor has taken over, and pulling in further is no longer something
|
||||
// this can do. Stopping is the honest answer; looping would only spend
|
||||
// another layout to be told the same thing.
|
||||
if (next.rx === current.rx && next.ry === current.ry) return current;
|
||||
|
||||
current = next;
|
||||
for (let index = 0; index < layer.childElementCount; index++) {
|
||||
this.placeSeat(layer.children[index], index, aspect, current);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
},
|
||||
|
||||
// Names that don't fit are shortened at a word, not chopped in the middle of one.
|
||||
//
|
||||
// A puck is 34px of text on a nine-handed phone, and the label wraps to two lines
|
||||
// and then ellipsises. What that produced for the four real names on the test table
|
||||
// was "Christo…", "Mike / from…", "Alexan…" — and "Mike from work" is the one of
|
||||
// those where the ellipsis is throwing away nothing the dealer needs. He is Mike.
|
||||
// The rest of it is how this table tells him from the other Mike.
|
||||
//
|
||||
// So a name that overflows drops whole trailing words until it fits: "Mike from
|
||||
// work" becomes "Mike from", then "Mike", and stops there with a real word rather
|
||||
// than a fragment. A single word that still doesn't fit is left to the ellipsis it
|
||||
// always had — there is no honest way to shorten "Christopher" that a dealer
|
||||
// glancing across a table would read faster than "Christo…".
|
||||
//
|
||||
// Written as three passes over all the seats rather than a loop per seat: each pass
|
||||
// writes to every puck and then reads back from every puck, so the browser lays the
|
||||
// felt out three times whatever the table's size. Interleaved, nine seats would
|
||||
// have cost nine layouts, and this runs on every render.
|
||||
fitSeatNames(layer) {
|
||||
if (!layer) return;
|
||||
const labels = [];
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = layer.children[index];
|
||||
const el = node && node.querySelector(".seatPuckName");
|
||||
if (!el || !el.textContent) return;
|
||||
const full = (player.name || "").trim();
|
||||
// The tooltip is the whole name whether or not this one gets shortened: a
|
||||
// label that wraps to two clamped lines was already hiding some of it.
|
||||
el.title = full;
|
||||
const words = full.split(/\s+/).filter(Boolean);
|
||||
if (words.length > 1) labels.push({ el, words, kept: words.length });
|
||||
});
|
||||
if (!labels.length) return;
|
||||
|
||||
for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
|
||||
const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
|
||||
if (!shorten.length) return;
|
||||
shorten.forEach(label => {
|
||||
label.kept = this.dropAWord(label.words, label.kept);
|
||||
label.el.textContent = label.words.slice(0, label.kept).join(" ");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
|
||||
// from", which is not a shorter name — it is a name that looks cut off, and it
|
||||
// reads worse than the ellipsis it was meant to replace. Whereas "Mike" is just
|
||||
// Mike, which is what the table calls him.
|
||||
//
|
||||
// Small and closed on purpose. This is not trying to understand names; it is the
|
||||
// handful of English joining words that turn up in the way people label each other
|
||||
// at a home game — "Mike from work", "Al with the hat", "Dee at the end". Anything
|
||||
// it doesn't know stays, which is the safe direction to be wrong in: a name that
|
||||
// keeps a word it could have dropped is merely long.
|
||||
NAME_TAIL_WORDS: new Set(["from", "of", "the", "at", "in", "on", "with", "and", "&",
|
||||
"de", "van", "von", "da", "di", "der", "la", "le"]),
|
||||
|
||||
// One word off, plus any joining word that leaves behind. Never below one.
|
||||
dropAWord(words, kept) {
|
||||
let next = Math.max(1, kept - 1);
|
||||
while (next > 1 && this.NAME_TAIL_WORDS.has(words[next - 1].toLowerCase())) next -= 1;
|
||||
return next;
|
||||
},
|
||||
|
||||
// A pass is a layout of the whole felt, and this runs on every render, so the budget
|
||||
// is small. Three is more than the names actually need: "Mike from work" reaches
|
||||
// "Mike" in one, because dropAWord takes the joining word with it, and a name of
|
||||
// four plain words reaches its first in three.
|
||||
SEAT_NAME_PASSES: 3,
|
||||
|
||||
// Past the box in either direction. Both matter: the label clamps to two lines, so
|
||||
// a name too long runs out of height, while a single word too wide for the puck
|
||||
// runs out of width without ever reaching the second line.
|
||||
overflowing(el) {
|
||||
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
|
||||
},
|
||||
|
||||
isRedSuit(card) {
|
||||
@@ -1400,6 +1828,67 @@ const Render = {
|
||||
}, at, showing);
|
||||
},
|
||||
|
||||
// Who still has cards to turn over, in the order they turn them over.
|
||||
//
|
||||
// The board half of this feature has always worked: every street asks for the cards
|
||||
// as they come off the deck. The hands half never did — the dealer entered five
|
||||
// community cards, pressed the showdown button, and the pot wizard told them
|
||||
// "Chris, Mike and Alex haven't turned over, so who won is still yours to say",
|
||||
// with nowhere in the sheet to turn anybody over. The one moment the tool is certain
|
||||
// to know the hands is the moment it stopped asking.
|
||||
//
|
||||
// Order is the showdown order the bar already announces out loud, then round the
|
||||
// table from there — the sequence the cards actually come over in, so the dealer is
|
||||
// never hunting for the seat the sheet has decided to ask about next.
|
||||
//
|
||||
// Anyone already entered is skipped. A dealer who typed a hand into the seat editor
|
||||
// mid-hand has answered this question, and asking again would offer to overwrite it.
|
||||
showdownCardQueue(state) {
|
||||
if (!state.trackBoard) return [];
|
||||
const live = Betting.liveSeats(state);
|
||||
if (live.length < 2) return [];
|
||||
|
||||
const order = HoldemState.liveOrderFromButton(state)
|
||||
.filter(index => live.some(seat => seat.index === index));
|
||||
const first = Betting.showdownOrder(state);
|
||||
const at = first ? order.indexOf(first.index) : -1;
|
||||
const rotated = at > 0 ? order.slice(at).concat(order.slice(0, at)) : order;
|
||||
|
||||
return rotated.filter(index => {
|
||||
const cards = state.seats[index].cards || [];
|
||||
return !(cards[0] && cards[1]);
|
||||
});
|
||||
},
|
||||
|
||||
// Asks each of them for their two cards, one player after another, and then hands
|
||||
// back so the pot can be resolved.
|
||||
//
|
||||
// Leaving the sheet stops the asking rather than skipping to the next player: a
|
||||
// dealer who closes it is telling the tool it has the wrong end of the stick, and
|
||||
// the answer to that is to get out of the way and let them settle the pot by hand,
|
||||
// not to put the same sheet in front of them four more times. The wizard still
|
||||
// names whoever didn't turn over, so nothing is lost by stopping early.
|
||||
openShowdownPicker(onFinished) {
|
||||
const queue = this.showdownCardQueue(this.state);
|
||||
const finish = () => onFinished();
|
||||
if (queue.length === 0) { finish(); return; }
|
||||
|
||||
const askNext = () => {
|
||||
const index = queue.shift();
|
||||
if (index === undefined) { finish(); return; }
|
||||
const cards = this.state.seats[index].cards || [];
|
||||
const slot = cards[0] ? 1 : 0;
|
||||
CardPicker.open(this.state, {
|
||||
onPick: () => {
|
||||
HoldemState.save(this.state);
|
||||
this.renderAll();
|
||||
},
|
||||
onDone: reason => (reason === "filled" ? askNext() : finish()),
|
||||
}, index, slot);
|
||||
};
|
||||
askNext();
|
||||
},
|
||||
|
||||
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
|
||||
// Worth calling out, because at a short-handed table the open seats are what
|
||||
// the dealer is looking for.
|
||||
@@ -1653,6 +2142,10 @@ const Render = {
|
||||
if (this.selectedSeatIndex === null) {
|
||||
title.textContent = "Select a seat";
|
||||
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||||
// Closing is the one size change a ResizeObserver never sees: the sheet
|
||||
// leaves by transform and visibility, and its box stays exactly as tall as
|
||||
// it was. Without this the toast would go on holding its place.
|
||||
if (this.publishSeatSheetHeight) this.publishSeatSheetHeight();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1668,8 +2161,52 @@ const Render = {
|
||||
this.renderChipCounts(form, player, index);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.renderSeatTotal(form, player);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
|
||||
body.appendChild(form);
|
||||
if (this.publishSeatSheetHeight) this.publishSeatSheetHeight();
|
||||
},
|
||||
|
||||
// Which of the sheet's three folded sections stand open, and what each says while
|
||||
// it is shut.
|
||||
//
|
||||
// The rule is the same for all three and it is about the seat rather than about the
|
||||
// dealer: a section that has something in it opens, a section that is empty stays a
|
||||
// line. That is what makes this progressive disclosure rather than a place to hide
|
||||
// things — nothing a dealer has already entered is ever one tap further away than
|
||||
// it was, and the sheet only carries the weight of what is actually on the chair.
|
||||
//
|
||||
// Hole cards are the one exception, and it is not really one: a table that asked to
|
||||
// be shown the cards is a table where every seat is about to have some, so the
|
||||
// slots open before the first card rather than after it.
|
||||
//
|
||||
// Called on open and after every commit, so a section fills in as the dealer types
|
||||
// — the glance beside "What they've put in" is the total, and it is the number the
|
||||
// boxes underneath are adding up.
|
||||
renderSeatDisclosures(form, player) {
|
||||
const set = (selector, hasContent, glance) => {
|
||||
const panel = form.querySelector(selector);
|
||||
if (!panel) return;
|
||||
// Only ever forced open. Once the dealer has opened a section by hand,
|
||||
// typing in it must not close it under them on the next keystroke, and
|
||||
// `open = false` on a re-render would do exactly that.
|
||||
if (hasContent) panel.open = true;
|
||||
const label = panel.querySelector(".seatGlance");
|
||||
if (label) label.textContent = glance || "";
|
||||
};
|
||||
|
||||
const cards = (player.cards || []).filter(Boolean);
|
||||
set(".cardsPanel", cards.length > 0 || !!this.state.trackBoard,
|
||||
cards.length ? cards.map(card => this.formatCard(card.toUpperCase())).join(" ") : "none yet");
|
||||
|
||||
const counted = Object.values(player.chipCounts || {}).some(n => Number(n) > 0);
|
||||
const contribution = HoldemState.computeContribution(
|
||||
player, this.state.chipLegend, this.state.basePotAmount);
|
||||
set(".contributionPanel", counted || player.amountOverride != null,
|
||||
Money.format(contribution));
|
||||
|
||||
const notes = (player.customFields || []).filter(field => field.label || field.value).length;
|
||||
set(".notesPanel", notes > 0, notes ? `${notes} ${notes === 1 ? "note" : "notes"}` : "");
|
||||
},
|
||||
|
||||
renderSeatTotal(form, player) {
|
||||
@@ -1681,6 +2218,11 @@ const Render = {
|
||||
this.hooks.save(this.state);
|
||||
this.updatePotBadge();
|
||||
this.renderSeatTotal(form, player);
|
||||
// The sections' summaries are downstream of nearly every field in the sheet —
|
||||
// a card picked, a chip counted, an amount typed — so they are refreshed on the
|
||||
// same path the total is. Safe on every keystroke for the same reason: this
|
||||
// writes to the summary labels and never to a field the dealer is inside.
|
||||
this.renderSeatDisclosures(form, player);
|
||||
this.refreshPuck(index);
|
||||
// The editor deliberately refreshes narrowly rather than redrawing everything —
|
||||
// a full render on every keystroke would take the name field out from under the
|
||||
@@ -1698,6 +2240,12 @@ const Render = {
|
||||
// after naming a player both stayed greyed out, still captioned "nobody is at
|
||||
// the table yet", because this path deliberately doesn't redraw everything.
|
||||
this.renderHeaderActions();
|
||||
// Third thing on this list for the third time the same reason. What a seat has
|
||||
// bought in for and what it is holding are the two halves of the bank check, so
|
||||
// this path is the one that moves "Balanced" to "Off by $300" — and the shut
|
||||
// Standings panel is where a dealer would see it, since the seat editor is over
|
||||
// the sidebar while they are typing.
|
||||
this.renderPanelGlances();
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
@@ -1795,7 +2343,12 @@ const Render = {
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".chipSwatch").style.background = chip.color;
|
||||
row.title = `$${chip.value} chips`;
|
||||
// Written into the row rather than hung off it as a `title`. A tooltip is
|
||||
// nothing on a touch screen, and this was the only thing telling five
|
||||
// identical number boxes apart — so the boxes were, in practice, unlabelled,
|
||||
// to the dealer and to a screen reader alike: the wrapping <label> had a
|
||||
// coloured dot in it and no text, which is no name at all.
|
||||
row.querySelector(".chipCountValue").textContent = Money.format(chip.value);
|
||||
const input = row.querySelector(".chipCountInput");
|
||||
input.value = player.chipCounts[chip.id] || 0;
|
||||
input.addEventListener("input", () => {
|
||||
@@ -1821,15 +2374,18 @@ const Render = {
|
||||
labelInput.addEventListener("input", () => {
|
||||
field.label = labelInput.value;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
field.value = valueInput.value;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||||
player.customFields.splice(fieldIdx, 1);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
@@ -1846,25 +2402,52 @@ const Render = {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
const colorInput = row.querySelector(".chipColorInput");
|
||||
const valueInput = row.querySelector(".chipValueInput");
|
||||
const removeBtn = row.querySelector(".removeChipColorBtn");
|
||||
colorInput.value = chip.color;
|
||||
valueInput.value = chip.value;
|
||||
|
||||
// "×" is the whole of this button, so without a name it is announced as a
|
||||
// button and nothing else — and there are five of them in a column, all
|
||||
// identical, each doing something different and none of them small. The
|
||||
// value is what tells them apart, and it changes as it is typed, so the
|
||||
// name is kept up with it rather than set once.
|
||||
const nameRemove = () => {
|
||||
const worth = Number(valueInput.value) || 0;
|
||||
removeBtn.setAttribute("aria-label", worth > 0
|
||||
? `Remove the ${Money.format(worth)} chip`
|
||||
: "Remove this chip colour");
|
||||
};
|
||||
nameRemove();
|
||||
|
||||
colorInput.addEventListener("input", () => {
|
||||
chip.color = colorInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
chip.value = Number(valueInput.value) || 0;
|
||||
nameRemove();
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
this.updatePotBadge();
|
||||
// This is a named list of everything a chip's value moves, and the
|
||||
// legend's own summary is on it: shut, the panel reads "5 colours ·
|
||||
// $1–$500", and re-pricing the top chip is exactly the edit that
|
||||
// changes it. Called here for the same reason as the rest of the list:
|
||||
// renderAll would write this field's own value back into it mid-
|
||||
// keystroke.
|
||||
//
|
||||
// Only that one. The bank check is not downstream of this, which is
|
||||
// worth saying because it looks as though it should be — a seat's chip
|
||||
// counts are priced by the legend for the puck and the seat editor's
|
||||
// total, but bankReconciliation asks chipsHeldBy, which is stackOf, and
|
||||
// stackOf reads the seat's typed stack or the table's default and never
|
||||
// opens the legend at all.
|
||||
this.renderPanelGlances();
|
||||
});
|
||||
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
|
||||
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
|
||||
this.hooks.save(this.state);
|
||||
this.renderAll();
|
||||
});
|
||||
// Through the hook rather than done here, because what this costs depends on
|
||||
// what the seats are holding — see removeChipColor in main.js.
|
||||
removeBtn.addEventListener("click", () => this.hooks.removeChipColor(chip.id));
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
@@ -1923,7 +2506,15 @@ const Render = {
|
||||
|
||||
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
|
||||
// picked), because both are showing the dealer the same announcement.
|
||||
buildPotBreakdown(pots, returns, nameFor, heading) {
|
||||
//
|
||||
// `maxLayers` caps how many pots get a line of their own, and only the bar passes
|
||||
// it. The bar is fixed over the felt, so nothing in it may grow with the table —
|
||||
// and this list did, one row per all-in, with no ceiling. Measured on a 360x640
|
||||
// phone: five players idle put the bar at 151px, six all-in at 303px, and twelve
|
||||
// all-in at 411px, which is 64% of the screen and leaves 96px of felt for twelve
|
||||
// seats. The wizard passes no cap: it is a dialog with room, opened at the point
|
||||
// the whole list is what you are working from.
|
||||
buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers, summarise } = {}) {
|
||||
if (pots.length < 2 && returns.length === 0) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
@@ -1936,7 +2527,7 @@ const Render = {
|
||||
wrap.appendChild(title);
|
||||
}
|
||||
|
||||
const addRow = (label, amount, who, className) => {
|
||||
const addRow = (label, amount, who, className, full) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `potLayer ${className || ""}`;
|
||||
|
||||
@@ -1951,6 +2542,11 @@ const Render = {
|
||||
const whoEl = document.createElement("span");
|
||||
whoEl.className = "potLayerWho";
|
||||
whoEl.textContent = who;
|
||||
// The line can still run out of room on a phone. A tooltip is not a mechanism
|
||||
// a keyboard reaches, which is why the count is in the text itself rather
|
||||
// than only here — this is the extra for anyone with a pointer, not the
|
||||
// answer to "how many hands am I asking for".
|
||||
if (full) whoEl.title = full;
|
||||
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(amountEl);
|
||||
@@ -1958,12 +2554,104 @@ const Render = {
|
||||
wrap.appendChild(row);
|
||||
};
|
||||
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
|
||||
// While the board is being run out, one line instead of three.
|
||||
//
|
||||
// The list earns its height whenever somebody still has a decision: a player
|
||||
// working out whether to call is owed the side pot they are calling into. Once
|
||||
// everyone is all-in nobody has a decision at all — the only thing left in the
|
||||
// hand is the dealer turning cards — and the ledger goes on standing there.
|
||||
//
|
||||
// It is not free standing there. Measured at 360x640 with twelve players all-in,
|
||||
// the bar was 267px of a 640px window: 42%, against the sweep's own "persistent
|
||||
// chrome under 30%", and it is the felt that pays, on the exact table that has
|
||||
// the most seats to fit. The main pot is the number a dealer says out loud, so
|
||||
// it stays; the layering is what the pot wizard is for, and the row has always
|
||||
// said so in as many words.
|
||||
if (summarise && pots.length > 1) {
|
||||
const sides = pots.slice(1);
|
||||
addRow(
|
||||
pots[0].label, pots[0].amount,
|
||||
this.describeEligible(pots[0].eligible, pots[0].eligible, nameFor),
|
||||
"",
|
||||
pots[0].eligible.map(nameFor).join(", "));
|
||||
addRow(
|
||||
`${sides.length} side pot${sides.length === 1 ? "" : "s"}`,
|
||||
sides.reduce((sum, layer) => sum + layer.amount, 0),
|
||||
"listed when you resolve",
|
||||
"potLayerMore",
|
||||
sides.map(layer => `${layer.label} ${Money.format(layer.amount)}`).join(", "));
|
||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// The two ends of the list are the live ones, and they are live for the same
|
||||
// reason: pots are paid from the newest side pot back down to the main pot, so
|
||||
// the top layer is the next thing that happens and the main pot is the last.
|
||||
// Those are also the two a dealer says out loud while the chips are being
|
||||
// stacked — "main pot 7,200, side pot 4,500". The layers in between are the
|
||||
// middle of that count-down, read at showdown rather than mid-hand.
|
||||
//
|
||||
// Elide only when it buys something: at the cap itself the middle is one row
|
||||
// and replacing one row with one row is pure indirection, so the whole list
|
||||
// stands until there are strictly more pots than the cap.
|
||||
const middle = maxLayers && pots.length > maxLayers ? pots.slice(1, -1) : [];
|
||||
const shown = new Set(middle.length ? [pots[0], pots[pots.length - 1]] : pots);
|
||||
|
||||
pots.forEach(pot => {
|
||||
if (!shown.has(pot)) return;
|
||||
addRow(
|
||||
pot.label, pot.amount,
|
||||
this.describeEligible(pot.eligible, pots[0].eligible, nameFor),
|
||||
"",
|
||||
pot.eligible.map(nameFor).join(", "));
|
||||
// Kept in place rather than appended, so the amounts still read in the
|
||||
// order the pots were made and the gap sits where the gap actually is.
|
||||
if (middle.length && pot === pots[0]) {
|
||||
addRow(
|
||||
`${middle.length} more side pots`,
|
||||
middle.reduce((sum, layer) => sum + layer.amount, 0),
|
||||
"listed when you resolve",
|
||||
"potLayerMore",
|
||||
middle.map(layer => `${layer.label} ${Money.format(layer.amount)}`).join(", "));
|
||||
}
|
||||
});
|
||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||||
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// Who can win a pot, said in the fewest names that answer it.
|
||||
//
|
||||
// Listing every eligible player spelled the same names out again for every pot, and
|
||||
// the lists that were too long to fit were the ones nobody needed: pot eligibility
|
||||
// is strictly nested — the main pot is everyone still live, and each side pot above
|
||||
// it drops whoever was all-in below — so the main pot's list contains every other
|
||||
// list, and the side pot a dealer actually announces by name is the short one at the
|
||||
// top. Measured on an iPhone 14: a single all-in five-handed already clipped the
|
||||
// main pot's line, and twelve-handed the first row asked for 521px of a 254px slot.
|
||||
//
|
||||
// Nesting is what makes the short form safe, and it is a property rather than a
|
||||
// habit — potCalculator.test.js checks it across 20,000 random tables — so "who is
|
||||
// in" and "who is out" are complements and the shorter of the two is never more than
|
||||
// half the table.
|
||||
//
|
||||
// The count leads because .potLayerWho is right-aligned with an ellipsis, so it is
|
||||
// the tail that gets eaten. A dealer who can only read the start of the line still
|
||||
// learns how many hands to ask for, which is the number the showdown turns on.
|
||||
describeEligible(eligible, everyone, nameFor) {
|
||||
const listOf = names => (names.length <= 1
|
||||
? (names[0] || "")
|
||||
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`);
|
||||
|
||||
if (eligible.length >= everyone.length) return `all ${eligible.length}`;
|
||||
|
||||
const inSet = new Set(eligible);
|
||||
const out = everyone.filter(id => !inSet.has(id));
|
||||
return out.length < eligible.length
|
||||
? `${eligible.length} in, not ${listOf(out.map(nameFor))}`
|
||||
: `${eligible.length} in, ${listOf(eligible.map(nameFor))}`;
|
||||
},
|
||||
|
||||
// ---- Session standings ----
|
||||
|
||||
renderStandings() {
|
||||
@@ -2343,8 +3031,6 @@ const Render = {
|
||||
if (!force && signature === this.lastHistorySignature) return;
|
||||
this.lastHistorySignature = signature;
|
||||
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
|
||||
@@ -66,6 +66,11 @@ const HoldemState = {
|
||||
// it has always been: no board on the felt, and the pot wizard asks who
|
||||
// won. On, it reads the showdown and answers that question itself.
|
||||
trackBoard: false,
|
||||
// Whether to stop the screen sleeping while a hand is live. On, because a
|
||||
// dealer who has to wake their phone between the flop and the turn is the
|
||||
// single most common way this tool gets in the way of the game it is meant
|
||||
// to be running. Only ever held while betting is open — see wakeLock.js.
|
||||
keepScreenAwake: true,
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
@@ -229,6 +234,12 @@ const HoldemState = {
|
||||
parsed.trackBoard = parsed.trackBoard === true;
|
||||
parsed.board = this.normaliseBoard(parsed.board);
|
||||
|
||||
// Note the direction: `!== false` rather than `=== true`. A table saved
|
||||
// before this setting existed has no opinion about it, and the useful guess
|
||||
// for a dealer who has never been asked is the helpful one. Only somebody
|
||||
// who has actually turned it off gets it off.
|
||||
parsed.keepScreenAwake = parsed.keepScreenAwake !== false;
|
||||
|
||||
parsed.deadButton = parsed.deadButton === true;
|
||||
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||
// An old save has no record of who was dealt last hand. Empty is the
|
||||
@@ -318,6 +329,48 @@ const HoldemState = {
|
||||
//
|
||||
// So the table can be written out as a file and read back in. Same shape that goes
|
||||
// into storage, wrapped in enough to recognise it later.
|
||||
// ---- Asking the browser to actually keep it -------------------------------
|
||||
//
|
||||
// "The night lives in this browser and nowhere else" is truer than it sounds.
|
||||
// localStorage is *evictable*: when a device runs short, the browser clears
|
||||
// site data to make room, and it starts with the sites it thinks nobody cares
|
||||
// about. Safari goes further and deletes script-created storage after seven days
|
||||
// without a visit — so a table saved at last month's game can simply not be there
|
||||
// at this one, with nothing having gone wrong and nobody having deleted anything.
|
||||
//
|
||||
// `persist()` is the way to say this data is not that. Granted, the storage can
|
||||
// only be cleared by the person whose browser it is. Chromium and Safari decide
|
||||
// silently on how much the site has been used and whether it's installed; Firefox
|
||||
// asks. All three want the request to come from something the dealer did, which is
|
||||
// why this fires on the first hand dealt rather than on page load.
|
||||
supportsDurableStorage() {
|
||||
return typeof navigator !== "undefined"
|
||||
&& !!navigator.storage
|
||||
&& typeof navigator.storage.persist === "function";
|
||||
},
|
||||
|
||||
async isDurable() {
|
||||
if (!this.supportsDurableStorage()) return false;
|
||||
try {
|
||||
return await navigator.storage.persisted();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Returns whether the browser said yes. A no is not a failure worth reporting at a
|
||||
// table — the tool behaves identically either way, and the only honest difference
|
||||
// is how confident the Save & reset panel is allowed to sound.
|
||||
async requestDurable() {
|
||||
if (!this.supportsDurableStorage()) return false;
|
||||
try {
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
BACKUP_KIND: "casino.holdem.table",
|
||||
BACKUP_VERSION: 1,
|
||||
|
||||
@@ -1139,6 +1192,84 @@ const HoldemState = {
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// What a chip colour is holding up.
|
||||
//
|
||||
// Removing one is not a change to a legend. computeContribution() prices a seat by
|
||||
// running the legend over that seat's chip counts, so a colour that leaves the
|
||||
// legend takes every chip counted in it off the table. Measured on a three-handed
|
||||
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
|
||||
// on one click of a 24px button, with nothing said and nothing shown.
|
||||
//
|
||||
// It does not come back, either. A colour added again is a new colour with a new
|
||||
// id, while the counts stay keyed to the one that left — so "put it back" restores
|
||||
// the row and none of the money.
|
||||
//
|
||||
// Hence this: the cost is answerable before the click rather than discovered at the
|
||||
// end of the night, when the box is short and nobody knows why.
|
||||
// The number here is the *difference*, asked by pricing every seat twice — once with
|
||||
// the colour and once without — rather than by multiplying the chips by their value.
|
||||
//
|
||||
// Those two are not the same, and the multiplication is the wrong one, because
|
||||
// computeContribution() does not always reach the chips. A seat with a typed amount
|
||||
// is priced from that and its chip counts are ignored, so a colour it holds is worth
|
||||
// nothing to it; and a seat whose chips all leave falls through to the table's base
|
||||
// pot — the standing ante — rather than to zero, so it loses less than its chips
|
||||
// were worth.
|
||||
// Both would put a number in the confirmation that the table then failed to match,
|
||||
// which is worse than no number at all — this is a warning whose whole job is to be
|
||||
// the truth.
|
||||
chipColorUsage(state, chipId) {
|
||||
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
|
||||
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
|
||||
|
||||
const legend = state.chipLegend || [];
|
||||
const without = legend.filter(entry => entry.id !== chipId);
|
||||
|
||||
const seats = state.seats
|
||||
.map((player, index) => {
|
||||
const before = this.computeContribution(player, legend, state.basePotAmount);
|
||||
const after = this.computeContribution(player, without, state.basePotAmount);
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
count: Number((player.chipCounts || {})[chipId]) || 0,
|
||||
amount: Money.round(before - after),
|
||||
};
|
||||
})
|
||||
// Signed, and not filtered to losses, because removing a colour can put the
|
||||
// table *up*. A seat whose chips are worth less than the table's standing
|
||||
// ante falls through to the ante once they are gone: found by the randomised
|
||||
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
|
||||
// moved the table by +186. Rare, but a warning that said "nothing changes"
|
||||
// there would be as wrong as one that named the wrong number.
|
||||
.filter(row => row.amount !== 0);
|
||||
|
||||
return {
|
||||
chip,
|
||||
seats,
|
||||
chips: seats.reduce((sum, row) => sum + row.count, 0),
|
||||
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Take a colour out, and take its counts with it.
|
||||
//
|
||||
// Leaving them behind is what the old code did — the seats kept counts against an
|
||||
// id no legend entry answers to any more, so the file carried money that nothing
|
||||
// could price and nothing could show. Clearing them makes the saved table say what
|
||||
// the screen says. The usage is measured before anything moves, so a caller can
|
||||
// report what it cost.
|
||||
removeChipColor(state, chipId) {
|
||||
const usage = this.chipColorUsage(state, chipId);
|
||||
if (!usage.chip) return usage;
|
||||
|
||||
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
|
||||
state.seats.forEach(player => {
|
||||
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
|
||||
});
|
||||
return usage;
|
||||
},
|
||||
|
||||
// Seats playing with chips the box has no cash against.
|
||||
//
|
||||
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Keeps the screen on while a hand is live.
|
||||
//
|
||||
// The dealer's phone spends the night face-up on the felt with nine people round it,
|
||||
// and the one thing it reliably does between the flop and the turn is dim, then lock.
|
||||
// Every hand, all night. A dozen rounds of work have gone into making the next step
|
||||
// readable at a glance, and none of it survives a black screen and a passcode with
|
||||
// four people waiting to know whether the bet is called.
|
||||
//
|
||||
// Held only while betting is actually open. A phone left on the table through a break,
|
||||
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
|
||||
// like any other phone — which is the difference between a tool that respects a battery
|
||||
// and one a dealer stops trusting with their evening.
|
||||
|
||||
const WakeLock = {
|
||||
// The browser's promise to stay awake. Null whenever we aren't holding one, which
|
||||
// includes every moment the browser has taken it back on us — see `start()`.
|
||||
sentinel: null,
|
||||
|
||||
// What the last sync decided we want. Kept separate from `sentinel` because
|
||||
// requesting one is asynchronous: a hand can be dealt and folded out inside the
|
||||
// await, and the answer to "should we still be holding this" has to be the current
|
||||
// one rather than the one that was true when the request went out.
|
||||
wanted: false,
|
||||
|
||||
// One request at a time. `sync()` runs off renderAll, which fires many times a
|
||||
// second during a busy street; without this, two overlapping requests both get
|
||||
// past the "already holding one?" check and the second sentinel leaks — the screen
|
||||
// then stays awake after the hand ends, with nothing left holding a reference to
|
||||
// turn it off.
|
||||
pending: false,
|
||||
|
||||
// Whether the dealer has been told this is happening. Once per page load, not once
|
||||
// per hand: the same toast at the top of all forty hands of a session would be the
|
||||
// single most annoying thing the tool does.
|
||||
told: false,
|
||||
|
||||
// Set by main.js so the toast's "Turn off" can actually turn it off.
|
||||
hooks: null,
|
||||
|
||||
supported() {
|
||||
return typeof navigator !== "undefined" && "wakeLock" in navigator;
|
||||
},
|
||||
|
||||
// The whole decision, with no browser in it, so it can be tested as what it is:
|
||||
// a rule about when a poker table needs the screen.
|
||||
shouldHold(state) {
|
||||
if (!state) return false;
|
||||
if (state.keepScreenAwake === false) return false;
|
||||
return state.bettingOpen === true;
|
||||
},
|
||||
|
||||
// Called after anything that could start or end a hand. Safe to call constantly.
|
||||
sync(state) {
|
||||
this.wanted = this.supported() && this.shouldHold(state);
|
||||
if (this.wanted) this.acquire();
|
||||
else this.release();
|
||||
},
|
||||
|
||||
async acquire() {
|
||||
if (this.sentinel || this.pending) return;
|
||||
this.pending = true;
|
||||
try {
|
||||
const sentinel = await navigator.wakeLock.request("screen");
|
||||
// The hand can have ended while that was in flight. Letting this one go
|
||||
// immediately is the only correct answer; holding it "just until the next
|
||||
// sync" is how a phone stays lit on an empty table.
|
||||
if (!this.wanted) {
|
||||
try { await sentinel.release(); } catch { /* already gone */ }
|
||||
return;
|
||||
}
|
||||
this.sentinel = sentinel;
|
||||
// The browser releases this itself when the tab is hidden, and tells us so.
|
||||
// Without this listener our `sentinel` would be a stale object and the
|
||||
// re-acquire in `start()` would decline to do anything.
|
||||
sentinel.addEventListener("release", () => {
|
||||
if (this.sentinel === sentinel) this.sentinel = null;
|
||||
});
|
||||
this.tell();
|
||||
} catch {
|
||||
// Battery saver, a hidden tab, a browser that simply says no. All of these
|
||||
// are the browser doing its job, and none of them is worth an error at a
|
||||
// poker table — the tool works exactly as it did before, the screen just
|
||||
// dims the way the phone's own settings say it should.
|
||||
} finally {
|
||||
this.pending = false;
|
||||
}
|
||||
},
|
||||
|
||||
async release() {
|
||||
const sentinel = this.sentinel;
|
||||
this.sentinel = null;
|
||||
if (!sentinel) return;
|
||||
try { await sentinel.release(); } catch { /* already released */ }
|
||||
},
|
||||
|
||||
// Said once, at the moment it first becomes true, with the way out attached.
|
||||
//
|
||||
// The alternative was a permanent indicator in the header, and the header is the
|
||||
// one part of this page that has been fought over for crowding more than any other.
|
||||
// A dealer needs to know their phone is behaving unusually exactly once; after that
|
||||
// it is a setting, and settings live in the sidebar.
|
||||
tell() {
|
||||
if (this.told) return;
|
||||
this.told = true;
|
||||
if (typeof Toast === "undefined") return;
|
||||
Toast.show("Keeping the screen on while a hand is live.", {
|
||||
actionLabel: "Turn off",
|
||||
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
|
||||
});
|
||||
},
|
||||
|
||||
// A screen wake lock is not something you take once. The browser drops it whenever
|
||||
// the document stops being visible — switching apps to check a rule, the phone
|
||||
// locking on its own before the first hand was dealt, a notification pulled down —
|
||||
// and it never comes back on its own. A tool that requests it at the start of the
|
||||
// hand and assumes it still has it is a tool that works until the first interruption.
|
||||
start(getState) {
|
||||
if (!this.supported()) return;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
this.sync(getState());
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
|
||||
+911
-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 |
|
||||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
||||
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
|
||||
| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table |
|
||||
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
|
||||
| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) |
|
||||
| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) |
|
||||
| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
@@ -38,19 +43,46 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, that no
|
||||
text is painted outside its box, that no seat covers another seat's writing, and that
|
||||
no bet chip is hidden behind a seat or another chip. It
|
||||
runs over the table, all five overlays and Quick Split, at thirteen viewports from a
|
||||
runs over the table, the raise pad, all five overlays and Quick Split, at thirteen
|
||||
viewports from a
|
||||
360px phone to a 1600px desktop and the same phones on their side — each viewport
|
||||
twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
|
||||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||||
which is a third of what is on the felt during a hand left unlooked at.
|
||||
|
||||
The raise pad is newer than the rest and was worth every second it costs. It is part
|
||||
of the bar rather than an overlay, so nothing here had a step that opened it, and it is
|
||||
both the tallest thing the bar ever holds and the only place in the tool a dealer types
|
||||
an amount. The first sweep that opened it found a 36px-tall field on every phone in the
|
||||
list: the `hover: none` rule that sizes fields for a thumb names the settings panels and
|
||||
the seat form, and the one field on the one surface built for thumbs was not in the
|
||||
list. It also found the rail squeezing its own status line under the pad, which is the
|
||||
kind of thing that only happens in the state nobody was looking at.
|
||||
|
||||
"Covered" and "out of view" are told apart before anything is hit-tested. An element
|
||||
clipped away by a panel that scrolls is not covered by whatever happens to be painted
|
||||
at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
Text is measured where it was painted, not where its box is. The two are different
|
||||
things and the difference is not academic: a name is centred, so its box is wider than
|
||||
its ink, and a name kept clear of the dealer's chip by a float is out of the way on the
|
||||
line beside the chip and full width on the line below. The check used to model that as
|
||||
"box minus padding", which is one way of keeping text out of somewhere and not the only
|
||||
one — it called every seat in the sweep an offender while the text sat exactly where it
|
||||
was meant to. Range rects ask the question directly, so padding, floats, centring,
|
||||
wrapping and a short label in a wide box all come out right without any of them being
|
||||
modelled.
|
||||
|
||||
Offenders say how much is covered rather than that something is. "Is under a badge"
|
||||
reads the same for a stack a dealer cannot read and for a badge's rounded corner
|
||||
grazing the last digit, and the difference decides whether it is worth fixing. Probing
|
||||
the whole line and counting turned the residual this sweep has carried for three rounds
|
||||
into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context
|
||||
only.
|
||||
|
||||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||||
depends on how many people are sitting down far more than on anything a viewport can
|
||||
@@ -68,6 +100,26 @@ felt height. The seats follow a felt that changes size on their own, being place
|
||||
percentages of it; the chips anchored inside them do not, being placed in pixels. They
|
||||
were laid out for a felt 23px taller than the one they were painted on.
|
||||
|
||||
## What the sweep taught the layout
|
||||
|
||||
For a long run of rounds the seat ring was *predicted* onto the table: work out the
|
||||
widest ring whose outermost puck still finishes inside the rail, place the seats, and
|
||||
trust it. The prediction is correct arithmetic and it was wrong on the page anyway,
|
||||
because every input to it moves after it is taken — the width search settles on a
|
||||
narrower puck, a name wraps to a second line or gets shortened out of one, the board
|
||||
appears in the middle. Each of those is up to 15px of puck height arriving after the
|
||||
answer. Ninety-six of the ninety-eight offenders this sweep could find were that.
|
||||
|
||||
So render.js now finishes the way the audit judges it. After the seats are placed it
|
||||
measures where they actually landed, and if the worst of them hangs 6px past the rail
|
||||
the ring comes in by 6px and they are placed again — the same question this file asks,
|
||||
asked by the code that has to get it right. Two passes, bounded, because each one is a
|
||||
forced layout of the whole felt and it runs after every tap a dealer makes.
|
||||
|
||||
That is the general shape of the lesson here: a layout that predicts itself has to be
|
||||
right about the future, and a layout that measures itself only has to be right about
|
||||
the present.
|
||||
|
||||
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
|
||||
@@ -107,6 +159,864 @@ something changed, and each time they only covered whatever had just been touche
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
and kept it for two rounds.
|
||||
|
||||
## Which rule wins
|
||||
|
||||
`feltShape.test.js` is here because of a change that measured as correct and was a
|
||||
no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside
|
||||
`@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set
|
||||
the same property back to 1. Both queries match a phone, the declarations have the same
|
||||
specificity, and the later one wins — so the property was 1 on every phone on the site
|
||||
while the rule that was supposed to change it sat there reading perfectly.
|
||||
|
||||
Nothing in the suite could have caught it and nothing in review did. A stylesheet is not
|
||||
a list of rules for viewports; it is a list of rules in an order, and the question "what
|
||||
does a 390px phone get?" has an answer that no single rule knows. So the test walks the
|
||||
media queries the way the cascade does — every declaration of a property on a selector,
|
||||
in source order, keeping the last one whose query matches — and asks what each of five
|
||||
real phone sizes actually ends up with. It carries just enough of a media-query engine
|
||||
for the forms this stylesheet uses, and refuses loudly rather than guessing when it
|
||||
meets one it doesn't know.
|
||||
|
||||
The same file records a second thing, which is a floor that was added and removed. The
|
||||
`max-height` that keeps the felt above the action bar has no lower bound, so a tall
|
||||
enough bar presses the table flat; flooring it at the desktop oval looked obviously
|
||||
right. Measured, it was worse: twelve players all in with nine side pots takes the bar
|
||||
to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its
|
||||
shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all
|
||||
twelve are visible. The slit the floor defended against turned out to be unreachable —
|
||||
it came from forcing the custom property by hand. A test now asserts the floor is
|
||||
*absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way.
|
||||
|
||||
## A property the wording depends on
|
||||
|
||||
`potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it
|
||||
leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The
|
||||
main pot is everyone still live, and each side pot above it drops whoever was all-in
|
||||
below, so every side pot's players are a strict subset of the pot beneath it.
|
||||
|
||||
That makes "who is in" and "who is out" complements, which is what lets the line name
|
||||
whichever of the two is shorter and never spell out more than half the table. Listing
|
||||
every eligible player instead meant the main pot's line was every name at the table and
|
||||
each side pot repeated it minus one — and the lines too long to fit were exactly the ones
|
||||
nobody needed, while the short ones at the bottom were the side pots a dealer actually
|
||||
announces by name.
|
||||
|
||||
The nesting check runs 20,000 random tables, and it asserts on its own generator as well
|
||||
as on the code: if a change to the shapes stopped producing side pots, every assertion in
|
||||
the loop would be vacuous and the test would still pass, so it also requires that more
|
||||
than ten thousand of those tables had one.
|
||||
|
||||
Writing that check is where a comparison against `Set.length` went in — a `Set` has
|
||||
`.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an
|
||||
assertion it failed everything; as a filter in a throwaway script it had passed
|
||||
everything, which is how it survived long enough to be written down. Both readings are
|
||||
silent. Prefer asserting the count you expect over comparing two things whose shapes you
|
||||
have not checked.
|
||||
|
||||
## Testing a fault that broke nothing
|
||||
|
||||
`sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because
|
||||
the fault it covers was not a bug. Every control worked and every id resolved. What was
|
||||
wrong was which panel a thing was in and what that panel was called.
|
||||
|
||||
The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and
|
||||
Session held the standings, a waiting list, the hand log and the save/load/reset buttons
|
||||
— a topic, a task, a log and three utilities under one word. It measured 1206px and 103
|
||||
visible elements against 555 and 29 for the next biggest, and "The table itself" sat
|
||||
1052px down inside it, so reaching Reset table meant opening a panel called Session and
|
||||
scrolling past the money and the log to find it.
|
||||
|
||||
Three things are asserted, and each of them is a claim a future edit could quietly undo:
|
||||
|
||||
- **The names say what is inside them.** The list is checked literally, because the
|
||||
failure mode is a word like "Session" that could hold anything.
|
||||
- **Each name is a heading.** A `<summary>` is announced as a button, so the sidebar's
|
||||
names — its whole navigation — were not reachable by heading. Splitting Session also
|
||||
removed the four subheads that had been the only headings down there, so without a
|
||||
heading in each summary the page would have come out of this with a worse outline than
|
||||
it went in with.
|
||||
- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is
|
||||
legitimately long. It counts *subjects*: a panel that needs its own headings to list
|
||||
its parts is holding more than one thing. Two subheads survive, and the test names
|
||||
them, because Settle up is the standings at the end of the night and Stacks in chips
|
||||
is the chip legend counted out — the same subject at a different moment, which is what
|
||||
a subhead is for.
|
||||
|
||||
The panels are split on the opening tag rather than parsed, which is sound only because a
|
||||
sidebar panel never contains another one; Table Setup's sub-panels carry a different
|
||||
class. That assumption is worth knowing about if one ever does.
|
||||
|
||||
## A warning is only worth having if its number is right
|
||||
|
||||
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
|
||||
24px button and was never a change to a legend. `computeContribution()` prices a seat by
|
||||
running the legend over that seat's chip counts, so a colour that leaves takes every chip
|
||||
counted in it off the table. Measured in a browser on a three-handed table: deleting the
|
||||
$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said
|
||||
afterwards, and no way back — a colour added again is a new colour with a new id, while
|
||||
the counts stay keyed to the one that left.
|
||||
|
||||
Most of the file is about the number in the warning rather than the removal, because a
|
||||
warning that overstates is worse than none. The obvious way to work it out — chips times
|
||||
value — is wrong in **both** directions:
|
||||
|
||||
- a seat with a typed amount is priced from that and its counts are never read, so a
|
||||
colour it holds is worth nothing to it;
|
||||
- a seat whose chips all leave falls through to the table's standing ante rather than to
|
||||
zero, so it loses less than its chips were worth.
|
||||
|
||||
So the figure is the difference between pricing every seat with the colour and without
|
||||
it, and a randomised check across 2,000 tables asserts that what the warning claims is
|
||||
exactly what the table then does. That check found a third case nobody would have written
|
||||
by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table
|
||||
**up** $186. The wording says so in that direction too.
|
||||
|
||||
The friction is conditional on purpose. A colour nobody has counted deletes on one click;
|
||||
only one that is holding money asks. A confirmation that fires every time is one nobody
|
||||
reads by the third night, and this is a tool where four other confirmations already
|
||||
compete for that attention.
|
||||
|
||||
## Every control says what it is
|
||||
|
||||
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
|
||||
had no accessible name, so it was announced as a button and nothing else — five of them
|
||||
in a column, identical, each deleting a different colour.
|
||||
|
||||
It treats a symbol as no name even though `×` is technically text. The rule is that a
|
||||
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
|
||||
Names are resolved the way the accessibility tree does it — `aria-label`, then
|
||||
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
|
||||
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
|
||||
skipped rather than judged, since it is not in the tree to begin with.
|
||||
|
||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||
how it was checked.
|
||||
|
||||
## Where the keyboard lands
|
||||
|
||||
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
|
||||
not be completely hidden by something the page has put on top of it. Tabbing the whole
|
||||
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
|
||||
under the action bar; on the desktop, four more under a toast.
|
||||
|
||||
They are checked as the fix rather than as the symptom, because the symptom needs a
|
||||
keyboard and this file has no automation of its own:
|
||||
|
||||
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
|
||||
least as much room as the fixed furniture standing at the bottom of the window. Only
|
||||
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
|
||||
down the right-hand side would demand scroll padding the height of the whole window;
|
||||
and sticky elements inside a panel that scrolls on its own are excluded, because
|
||||
scroll-padding on the document would do nothing about them.
|
||||
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
|
||||
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
|
||||
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
|
||||
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
|
||||
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
|
||||
|
||||
### Half a fix looks exactly like a whole one
|
||||
|
||||
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
|
||||
applies when the browser has to scroll to reach the control at all; a control the browser
|
||||
already considers visible is one it will not scroll to, and a fixed overlay on top of it
|
||||
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
|
||||
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
|
||||
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
|
||||
outlast an eight-second toast.
|
||||
|
||||
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
|
||||
and, when the page has no room left to scroll (26px available against 94px wanted, at
|
||||
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
|
||||
well would have fixed it by making the page grow and shrink by 64px every time anything
|
||||
was announced, which is worse than the fault.
|
||||
|
||||
## A sweep only measures the states it reaches
|
||||
|
||||
The action bar is fixed over the felt, so its height is the felt's budget — and the bar
|
||||
carries the side-pot breakdown, which drew one row per all-in with no ceiling. Measured
|
||||
on a 360x640 phone: five players idle put the bar at **151px**, six all-in at **303px**,
|
||||
and twelve all-in at **411px** — 64% of the screen, leaving **96px of felt** to draw
|
||||
twelve seats in.
|
||||
|
||||
Nothing here could see it, and the reason is worth writing down. Every check in
|
||||
`uiAudit.js` asks whether some element is covered, clipped or unreachable; not one asked
|
||||
how much room the covering element had taken. But the deeper miss was the sweep itself:
|
||||
all 294 audits drove their table with `Call` and `Check`, so **no table in the history of
|
||||
this file ever had a player all-in**, and the one thing on the bar whose height depends
|
||||
on the table was empty in every single measurement. `runUiAudit.js` now has a fourth
|
||||
shape, twelve players shoved in for twelve different amounts — there is no one-tap shove,
|
||||
so it takes the raise pad's All-in preset and confirms, or `Call all-in` for anyone who
|
||||
cannot cover.
|
||||
|
||||
- **`furnitureBudget()`** — the fixed bottom band, measured against the window. Failing
|
||||
above **50%**, which is the line that needs no taste to defend: furniture larger than
|
||||
the content it serves is a shape fault whatever it is showing. Warning above **30%**,
|
||||
the published guidance for persistent chrome, which the shortest phone does not meet at
|
||||
rest — the bar is 151px of a 640px screen before anything happens — and a sweep that is
|
||||
always red stops being read.
|
||||
|
||||
### Cutting the middle out of a list, and which end to keep
|
||||
|
||||
The cap is four layers, and past it the bar draws the main pot, one line for everything
|
||||
between, and the newest side pot. The two ends are not an arbitrary choice: side pots are
|
||||
paid **from the newest one back down to the main pot**, so the top layer is the next thing
|
||||
that happens and the main pot is the last, and those are exactly the two a live dealer
|
||||
says out loud while the chips are being stacked — *"main pot 7,200, side pot 4,500"*. The
|
||||
layers between are the middle of that count-down, wanted at showdown, which is the pot
|
||||
wizard — the same builder, passing no cap, in a dialog that scrolls.
|
||||
|
||||
`potBreakdownRoom.test.js` checks the parts that could go wrong quietly:
|
||||
|
||||
- the row count is a function of the cap, not of the table — eight, nine, ten, eleven and
|
||||
twelve all-in all draw three lines
|
||||
- the drawn amounts still add up to every pot, so a dealer counting the announcement
|
||||
against the chips in the middle finds no shortfall the tool caused
|
||||
- the elision sits **where the gap is**, not appended after the list, so the amounts still
|
||||
read in the order the pots were made
|
||||
- it never fires when it would not buy anything: at exactly the cap the middle is one row,
|
||||
and swapping one row for one row loses an amount and gains a sentence
|
||||
|
||||
Three injected faults — the cap ignored, the summary carrying only the first elided pot's
|
||||
money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests
|
||||
respectively, and the audit check reports the original 64% when the cap is taken out.
|
||||
|
||||
## Two empty rows were setting how far apart twelve seats had to sit
|
||||
|
||||
Every seat puck carries four rows: the name, the hole cards, the amount that player has
|
||||
put in, and their stack. Two of those are empty most of the time, and both held their
|
||||
space anyway.
|
||||
|
||||
The amount pill was worse than empty space. It has a background, a pill radius and side
|
||||
padding, so with no text it painted an **8x11px green lozenge** — measured on **seven of
|
||||
nine seats** at an ordinary table. One of those is a component holding its place; nine is
|
||||
a rash, and a dealer has to learn to ignore a colour that on the other two seats means
|
||||
"this player has money out".
|
||||
|
||||
The space was not free either. `seatRingRadii` sizes the ring from the tallest puck on
|
||||
it, so two rows nobody had filled were setting the spacing for the whole table:
|
||||
|
||||
| | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPhone 14, 9 seats | 78x88 puck | **78x63** |
|
||||
| iPhone 14, 12 seats | 44x65 | **66x55** |
|
||||
| Pro Max, 9 seats | 84x88 | **96x63** |
|
||||
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
|
||||
|
||||
- **`emptyDecoration()`** — nothing inside `#pokerTableWrap` may paint a background or a
|
||||
border with no content in it. The rule is about paint, not space: an element may hold
|
||||
its place invisibly, and several do. With the old reservation restored it reports all
|
||||
seven pills; live it reports none.
|
||||
|
||||
The one exemption, `.betChipDisc`, is on the list because the check reported it, not
|
||||
because it was predicted — it is a 13x13px circle filled with the denomination colour of
|
||||
the chip beside it, so the colour *is* the content. Three other exemptions were written
|
||||
from memory first and matched no element on the page at all, which is the argument for
|
||||
building that list from a run.
|
||||
|
||||
### The collapse is only safe because the cache key moved
|
||||
|
||||
`resolveSeatLayout` measures ten candidate widths, each one a forced layout, and caches
|
||||
the result against `seatShapeSignature()`. That signature listed the names and the dealer
|
||||
seat — everything that could wrap a name onto a second line. It did not mention the two
|
||||
rows, because they always held their space and so could never change a puck's height.
|
||||
|
||||
Once they collapse, they can. `seatPuckShape.test.js` checks the key moves when the first
|
||||
chips go in and when a hole card is entered, and — just as important — that it does *not*
|
||||
move for the second player to bet, or when an amount merely gets wider. It is a boolean
|
||||
over the whole table because that is what the measurement is: `measuredSeatBox` takes the
|
||||
tallest puck on the ring, so what matters is whether anybody has one. A per-seat key would
|
||||
be correct and would throw away the cache on every single tap.
|
||||
|
||||
Measured on the running page: the ring settles by 24-26px when the blinds are posted —
|
||||
and it settled by **23-25px there before this round too**, because the action bar changes
|
||||
height at that moment and the felt is measured against it. The one place the ring moves
|
||||
is a place it already moved.
|
||||
|
||||
### A test that passed for the wrong reason
|
||||
|
||||
The signature joins names on a control character so that "ann" + "abel" and "anna" + "bel"
|
||||
stay distinguishable. The first version of that test used `Ann`/`Abel` against
|
||||
`Anna`/`Bel` — which can never collide whatever the separator, because the capitals land
|
||||
in different places. It passed with the separator deleted and proved nothing. Lower case
|
||||
is what makes it a test. Four faults were injected in total; the other three (dropping the
|
||||
two flags, letting the count into the key instead of a boolean, and counting only hand
|
||||
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
|
||||
|
||||
## Room the felt had and the type never got
|
||||
|
||||
The felt grows with the window and the pucks did not — 96px was the only ceiling there
|
||||
was. Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop
|
||||
and **8% at 1440px and beyond**, with the stack figure a dealer reads at **10px on a
|
||||
2560px monitor**, the same 10px as on a 360px phone.
|
||||
|
||||
Two things stop the fix costing more than it buys, and `seatWidthSearch.test.js` checks
|
||||
both:
|
||||
|
||||
- **Every candidate width is a forced layout.** Raising the ceiling for everybody took
|
||||
the layout pass from 24ms to 73ms twelve-handed on a desktop. A screen that cannot use
|
||||
the wider widths must not measure them, so a phone is checked to measure *exactly* the
|
||||
ten widths it measured before — same list, same order — and the measured cost on a
|
||||
phone is unchanged (10.7ms against 10.4ms, which is noise).
|
||||
- **A wide puck on a narrow felt is worse than a small one.** The first version let
|
||||
phones through, and a 144px puck on an iPhone's 278px ring is half the width of the
|
||||
table: six-handed, all six seats sat over the rail by 15px against 5px before, and it
|
||||
read as a stack of cards rather than a table with people around it.
|
||||
|
||||
So the ceiling is a fifth of the felt's **shorter** span, capped at 144. Five is measured,
|
||||
not chosen: the shorter span is 233-303px on every phone in the sweep including sideways,
|
||||
and 617-714px on tablets and desktops, so any divisor between 3.2 and 6.4 separates them.
|
||||
The test pins both ends, so if the felt ever changes shape enough to bring those together
|
||||
it says so rather than letting the phones quietly start paying.
|
||||
|
||||
Above the ordinary ceiling the puck also takes the larger type — `.seatsRoomy`, two steps
|
||||
up the scale for the two numbers read across the table and one for the chair number.
|
||||
Steps rather than a `clamp()`, for the same reason the tight set is: the width is a
|
||||
continuum and the type is a set of deliberate sizes.
|
||||
|
||||
| nine-handed | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPad puck / name | 96px / 12px | **134px / 15px** |
|
||||
| desktop 1440 puck / name | 96px / 12px | **140px / 15px** |
|
||||
| seats as a share of the felt, 1440 | 8% | **12%** |
|
||||
| every phone, every seat count | unchanged | unchanged |
|
||||
|
||||
### What the rail check is actually measuring, which is not the rail
|
||||
|
||||
Found while checking the above and **not fixed** — written down here because the number is
|
||||
worth having and the fix is not a small one.
|
||||
|
||||
`#pokerTable` is `border-radius: 50%`. Both the settle pass in `render.js` and
|
||||
`seatsOnTheTable()` in the audit compare a puck against the four edges of the table's
|
||||
**bounding box**, which is a different and much slacker question than the one the message
|
||||
"hangs Npx past the rail" claims to answer: at the top and bottom of the ring the oval
|
||||
curves away from its own box, so a puck can be comfortably inside the box and visibly
|
||||
outside the felt.
|
||||
|
||||
Measured against the ellipse instead — a point is outside when `(dx/a)² + (dy/b)² > 1`,
|
||||
and the ray from the centre crosses the boundary at `1/√t` of the way out — across 35
|
||||
configurations the check is seeing **303px of overhang as zero**, including **46px on a
|
||||
phone held sideways** that has been there for as long as the check has.
|
||||
|
||||
Settling against the ellipse was tried and reverted, because it is not the local fix it
|
||||
looks like. Pulling the ring in far enough to clear the oval crowds the seats, and on a
|
||||
twelve-handed phone with everybody all-in it put seat 3's centre inside seat 4 — the one
|
||||
trade the width search explicitly refuses to make, since a dealer can see a puck over the
|
||||
rail but cannot see a tap answering for the wrong player. Guarding against that instead
|
||||
leaves the ring out and the overhang worse: the sweep went from 6 failing configurations
|
||||
to 77, with 355 rail reports.
|
||||
|
||||
Which is the finding. On a crowded table **keeping the seats off the rail and keeping them
|
||||
off each other are in genuine conflict**, and resolving it needs the two settled together
|
||||
rather than one bolted onto the other.
|
||||
|
||||
### And then measured again, and dropped
|
||||
|
||||
Re-probed across 40 configurations — four table shapes at ten viewports — the gap is real
|
||||
and systematic: **31 of 40 sit further past the oval than the box says**, worst 34px
|
||||
against a box overhang of 0 everywhere. The 303px above was the all-in shape, which is the
|
||||
extreme rather than the case.
|
||||
|
||||
Then it was looked at rather than measured, and 12–17px of a rounded puck's corner
|
||||
crossing the rail reads as players *at* a rail — which is what the thing is a picture of.
|
||||
The measurement gap is worth writing down and the appearance is not worth a round, so the
|
||||
lead is closed rather than carried forward. Worth the re-measure to find that out: the
|
||||
number that made it look urgent was the worst cell in the table, not a typical one.
|
||||
|
||||
## Eight rows that said only their own names
|
||||
|
||||
The sidebar is eight `<details>`, and every one measured exactly 60px shut with nothing on
|
||||
it but its label — 480px of a phone's screen, and 480px of a 660px desktop column, spent
|
||||
saying "Standings", "Hands", "Waiting list". Whether the box balances, whether anyone is
|
||||
waiting for a seat, how many hands have been played: the page had **all three worked out
|
||||
already**, and charged a tap for each of them, on rows two thirds of which were empty.
|
||||
|
||||
That is the disclosure pattern's named failure — a header you have to open to find out
|
||||
whether it was worth opening — and the answer the design systems give is the same one:
|
||||
put the status on the header. So each shut panel now carries the one fact you would have
|
||||
opened it for, and the panel keeps the sentence. "Off by $300" is the alarm; *which seat
|
||||
is holding chips nobody wrote down* is a paragraph, and that is the half that belongs
|
||||
behind the tap.
|
||||
|
||||
Two rules hold it together, and `panelGlance.test.js` checks both.
|
||||
|
||||
**A glance appears only when it has something a dealer would act on.** Eight rows that
|
||||
always say something are eight rows that say nothing again, and a row reading "Nobody
|
||||
waiting" all night is the clutter this went in to remove. So no waiting list, no notes and
|
||||
no hands yet are all silence, and the bank check stays quiet over a table nobody has
|
||||
bought into — which is what the panel does with its own check, for the same reason.
|
||||
|
||||
**A glance never says something the panel under it would contradict.** It is read off the
|
||||
same state, never off the panel's DOM — a shut `<details>` has no rendered body to read —
|
||||
and one test walks three buy-in figures asserting the glance's colour agrees with
|
||||
`bankReconciliation`'s verdict and its number carries the same difference.
|
||||
|
||||
The layout half is `glancesFitTheirRows` in the audit, and it took three goes.
|
||||
|
||||
The first version asked only whether a row had grown taller than a row with nothing on it,
|
||||
which sounds like the whole question and is not: a glance floated back into flow and
|
||||
allowed to wrap **sailed straight through it**, because a float does not expand the box it
|
||||
sits in — two lines of note hung out of a row that still measured exactly 44px. Asking
|
||||
where the glance actually ended up, in both axes, is the question that has an answer.
|
||||
|
||||
The second version was wrong in the other direction, and **the sweep caught it on its
|
||||
first clean run**: 94 failing configurations, 91 of the offenders this check's own. It
|
||||
takes the shortest row in the sidebar as its idea of a bare one, and the Keyboard panel is
|
||||
`display: none` under `pointer: coarse` — a touch device has no keys to press — so on
|
||||
every viewport visited with a thumb the bar was a row that wasn't drawn at all, 0px, and
|
||||
all seven real rows read as grown. Seven rows times thirteen viewports is exactly the 91.
|
||||
Rows that aren't rendered aren't rows.
|
||||
|
||||
Which is the point of running the sweep against a check as well as against the page. A
|
||||
narrowing fix has to be shown not to have blinded the thing it quietened, so the four
|
||||
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
|
||||
breaking it — and each is still caught, while the unbroken page stays clean.
|
||||
|
||||
## Half a feature, and the half that was missing
|
||||
|
||||
The card feature has always had two halves. Turn on the setting and every street asks
|
||||
which cards came off the deck, and puts them on the felt. That half worked, on the
|
||||
phone and on the desk, in a normal hand and in an all-in run-out.
|
||||
|
||||
The other half never got asked for at all. A dealer who entered all five community
|
||||
cards, pressed **Showdown — resolve pot**, and got a wizard that said
|
||||
|
||||
> Christopher, Mike from work, Alexandra and Bob haven't turned over, so who won is
|
||||
> still yours to say.
|
||||
|
||||
with nothing anywhere in that sheet to turn anybody over with. The only route in was to
|
||||
close the wizard, tap a seat puck, find the two card slots in the seat editor, do it
|
||||
four times, and re-open the wizard — with the pot on the table and four people waiting.
|
||||
The one moment the tool is certain to know the hands is the moment it stopped asking.
|
||||
|
||||
So the showdown button now asks, exactly the way the street button does: the sheet opens
|
||||
on the first player, takes two cards, and moves to the next. `showdownCards.test.js` is
|
||||
the list it asks from — who is on it and in what order — and three rules hold it
|
||||
together, one test each way:
|
||||
|
||||
**Only when the dealer asked.** The setting is off by default, and with it off the list
|
||||
is empty. A table that never wanted this never sees a sheet.
|
||||
|
||||
**Only what isn't known.** A hand already entered is an answer already given. Asking
|
||||
again offers to overwrite it, so an entered hand drops out of the list and the rest keep
|
||||
their order.
|
||||
|
||||
**In the order the cards come over.** The bar already announces "Alexandra shows first —
|
||||
last to bet or raise" a line above the button. Asking about somebody else first would be
|
||||
the tool contradicting itself in the same breath, so the list starts where the bar says
|
||||
and goes round the table from there.
|
||||
|
||||
### The sheet has to be leavable, and leaving it has to mean something
|
||||
|
||||
Escape, Done and a tap on the scrim all closed the picker already. What they didn't have
|
||||
was a way to say *why*, and a chain of four sheets needs to know the difference: a sheet
|
||||
that filled its last slot means "next player", and a dealer closing one means "stop".
|
||||
|
||||
So `close()` takes a reason. Two callers pass nothing and don't care. The showdown chain
|
||||
does, and it stops rather than skipping — a dealer who closes the sheet is saying the
|
||||
tool has the wrong end of the stick, and the answer to that is to get out of the way, not
|
||||
to put the same sheet in front of them three more times.
|
||||
|
||||
Stopping early is not a dead end, which is the part worth checking by hand. The wizard
|
||||
still names whoever didn't turn over; closing it leaves **Showdown — resolve pot** on the
|
||||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||||
is computed from the cards on the table rather than remembered from the last attempt.
|
||||
|
||||
## A scroll was an edit, and it reached the saved table
|
||||
|
||||
The worst bugs in this tool are the ones that move money without saying so, and this one
|
||||
was triggered by reaching for another panel.
|
||||
|
||||
A focused `<input type="number">` is a spinbutton, and a browser lets the mouse wheel
|
||||
drive it. Measured on the running page at 1280x900 with the sidebar open:
|
||||
|
||||
| gesture | before | after | saved? |
|
||||
|---|---|---|---|
|
||||
| focus the small blind, scroll up | $5 | **$5.50** | yes |
|
||||
| focus a seat's stack, scroll up | $500 | **$501** | yes |
|
||||
|
||||
Both go straight through to `localStorage`, because these fields save on `input` like
|
||||
everything else here. Nothing is announced, nothing looks wrong, and the tool has
|
||||
invented a chip — on a table where the whole point is that the chip count is right.
|
||||
|
||||
The rule that fixes it is narrow on purpose. Every *other* way of driving a spinbutton is
|
||||
deliberate: the arrow keys, the stepper arrows, typing. Scrolling is the one actuation
|
||||
nobody means, so it is the only one taken away. Verified after the change: the value
|
||||
holds, one ArrowUp still moves it, typing still works, **and the page still scrolls** —
|
||||
0 to 300px, which matters, because the obvious fix does not.
|
||||
|
||||
### Why it blurs instead of cancelling
|
||||
|
||||
`preventDefault()` on the wheel stops the value moving and stops the page moving with it.
|
||||
That turns a silent bug into a sidebar that mysteriously refuses to scroll, which is a
|
||||
worse trade: the dealer now has a problem they can see and can't explain, instead of one
|
||||
they can't see. Dropping focus lets the scroll do exactly what was asked and leaves the
|
||||
number alone.
|
||||
|
||||
### One guard that was checked and found to be decoration
|
||||
|
||||
The predicate starts with `field.tagName !== "INPUT"`. Injecting a fault into it — taking
|
||||
it out entirely — changed nothing: every test still passed, because nothing but an
|
||||
`<input>` carries `type="number"`, so the type check below it already excludes every
|
||||
textarea, select and button on the page.
|
||||
|
||||
That is written into the comment rather than quietly kept. A guard that cannot fail is
|
||||
fine to leave in for symmetry with `selectsOnFocus` beside it; a guard that cannot fail
|
||||
and is *presented* as load-bearing is how the next person wastes an afternoon.
|
||||
|
||||
The page's one `<select>` — which way the blinds move — was the obvious next suspect and
|
||||
is not one: focused, hovered and scrolled 300px in Chromium it stays on "clockwise". So
|
||||
it is left out of the rule rather than defended against, and the check keeps meaning
|
||||
exactly one thing.
|
||||
|
||||
## The motion setting was honoured in the three places it mattered least
|
||||
|
||||
The stylesheet had answered `prefers-reduced-motion` for a while. All three places it did
|
||||
were the wrong size of thing: a 2px press-down on a button, a checkbox's tick, and the
|
||||
acting seat's pulse. What none of them covered was motion that moves something *across
|
||||
the screen* — and for anyone with a vestibular disorder that is the part that matters. A
|
||||
button dipping under a thumb is not what makes people ill.
|
||||
|
||||
Four things were doing it, and the three largest were in JavaScript, where a stylesheet
|
||||
cannot reach:
|
||||
|
||||
- the seat editor, a bottom sheet that slides the full height of itself up from
|
||||
off-screen every time a dealer taps a chair, which on a busy table is most of the taps
|
||||
they make all night;
|
||||
- the toast, sliding in from below;
|
||||
- and three `scrollIntoView({ behavior: "smooth" })` calls — the most provocative of the
|
||||
lot, because what moves is the whole viewport rather than one control.
|
||||
|
||||
The scroll calls now go through `Render.bringIntoView`, which reads the setting per call
|
||||
rather than at page load: a dealer who turns it on mid-session should be taken seriously
|
||||
without reloading, and the table is a thing they cannot afford to reload lightly.
|
||||
|
||||
The toast keeps its fade and loses its travel. Deleting the animation outright is the
|
||||
wrong answer — something that appears already in place reads as a rendering glitch rather
|
||||
than as news — and the guidance that says to cut movement says to keep *something*.
|
||||
|
||||
The sheet needed one more thing than the obvious. Its `visibility` transition carries a
|
||||
0.25s delay so the slide-out stays on screen for its own animation; with the slide gone,
|
||||
that same delay leaves a sheet that has already jumped away still painted for a quarter
|
||||
of a second. It flips at once under reduced motion, in both directions.
|
||||
|
||||
### A list whose membership was arbitrary, because it was solving another problem
|
||||
|
||||
The press-down block held nine `:active` selectors and looked deliberate. It wasn't. It
|
||||
had been written to stop a *stuck press state* when a tap turns into a scroll, and it
|
||||
reached for the reduced-motion query as the nearest available proxy — so it covered
|
||||
`.dealerChip` and `.raiseQuickBtn` while missing `.actionBtn`, the class on Fold, Call,
|
||||
Raise and Post blinds & deal, which is to say the most-tapped control in the tool.
|
||||
|
||||
Read as its own rule, membership is not a judgement call: **every `:hover` and `:active`
|
||||
transform in the file belongs there.** There are sixteen. Nine were listed. The other
|
||||
seven are now.
|
||||
|
||||
### The scan that found the last three, and the injection that found the scan
|
||||
|
||||
The general test scans the stylesheet for pointer transforms and fails on any without an
|
||||
escape, so a new one cannot be added without the block being updated. It took two goes,
|
||||
and both faults are worth keeping written down.
|
||||
|
||||
The first version scanned line by line: remember the last line ending in `{`, then look
|
||||
for lines starting with `transform:`. A deliberately broken version was run at it — a new
|
||||
hover lift, uncovered — and the scan **passed**. The rule had been written on one line,
|
||||
so the selector and the declaration were in the same string and neither pattern matched.
|
||||
|
||||
Rewritten to parse blocks, it caught that. It also immediately failed on the *clean*
|
||||
file, which is how the last three came to light: a multi-line comma-separated group had
|
||||
been truncated to its final line by the old scan, so `.toastAction`, `.toastClose` and
|
||||
`.potWizardChoice` had never been looked at by anything. A grouped rule is nine separate
|
||||
promises, and it is now split on commas and checked one at a time — comparing the joined
|
||||
string against the block would only have passed if both lists happened to be written in
|
||||
the same order.
|
||||
|
||||
## Two pixels, and every seat on the felt paid for them
|
||||
|
||||
The sweep had carried the same residual for several rounds: on the two smallest phones
|
||||
with twelve players, seats painted over each other's names and stacks. It read like a
|
||||
geometry problem — twelve pucks, not enough rim — and the layout code had already been
|
||||
round that loop twice, deriving the ring from the felt and searching for a puck width
|
||||
that clears.
|
||||
|
||||
It was a wrapped label.
|
||||
|
||||
`"ALL IN"` is the only thing that ever lands on the stack line with a space in it. A
|
||||
stack figure has no spaces, so it can only overflow, and the `puckAmountLong` and
|
||||
`puckAmountHuge` sizes exist to stop it doing that. Nothing had ever asked what the line
|
||||
does when it *can* break. Measured at 360x640 with twelve players all-in: it wanted 36px
|
||||
and had 34, so it broke onto a second line, and an 11px line became 22.
|
||||
|
||||
That is not a typography complaint, which is why it survived so long — it looks fine.
|
||||
The puck went from 55px tall to 66, and puck height is precisely what the ring is short
|
||||
of. At the ends of an ellipse consecutive seats are separated *vertically*, so a puck
|
||||
11px taller is a puck 11px further into its neighbour. The layout search then did exactly
|
||||
what it is written to do: tried every width it has, found none that cleared, and fell
|
||||
back to the narrowest — which, being the narrowest, is also the one that wraps hardest.
|
||||
|
||||
So the fix is the rule every design system already gives a badge: one line, always.
|
||||
|
||||
### Not wrapping is only half an answer
|
||||
|
||||
A line that cannot wrap and still does not fit paints over the seat next door instead —
|
||||
the same failure, arriving sideways. So the mark has to actually *fit* 34px, and it
|
||||
wanted 36.
|
||||
|
||||
Of the three things that separate "ALL IN" from a grey money figure at a glance — a warm
|
||||
colour, a bold weight, and wider letters — the letter-spacing is the one that costs
|
||||
pixels: 3.6px of the 36. On the tight tier it goes, and the other two stay. Measured
|
||||
after: 33px of ink in a 34px box on a Galaxy S8, and the full spacing everywhere else,
|
||||
because no other puck is short of the room.
|
||||
|
||||
The three of them are pinned in `allInMarker.test.js` against the stylesheet itself —
|
||||
including the assertion that the *ordinary* spacing still doesn't fit, so that if someone
|
||||
later widens the puck and makes the whole problem moot, the test says it is measuring the
|
||||
wrong thing rather than passing quietly.
|
||||
|
||||
### And the bar was standing on the felt while nobody could act
|
||||
|
||||
Halving the overlap was not clearing it, and the rest of the shortage was not on the
|
||||
felt at all. The sweep had been saying so for rounds, in a warning rather than a failure:
|
||||
at 360x640 with twelve players all-in the action bar was **267px of a 640px window — 42%**,
|
||||
against its own "persistent chrome under 30%".
|
||||
|
||||
Two things in it were being shown at a moment when they could not be used.
|
||||
|
||||
The **side-pot list** is decision support. A player working out whether to call is owed
|
||||
the side pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
Once everyone is all-in there is no decision left to support — the only thing that
|
||||
happens for the rest of the hand is the dealer turning cards — and the list went on
|
||||
standing there. During a run-out it now collapses to the main pot, which is the number a
|
||||
dealer says out loud, and one line for everything else, which is what the pot wizard is
|
||||
for and what that row has always said in as many words.
|
||||
|
||||
The **status row** said the player count twice in the same breath: a `12 players` chip,
|
||||
and then `All-in — running out the board (12 players)` immediately to its right. That
|
||||
reads like two different figures being quoted, and on a 360px phone it cost the sentence
|
||||
a second line.
|
||||
|
||||
Measured across the three, on the table shape with the most seats to fit:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| Bar, Galaxy S8 | 267px (42%) | 249px (39%) |
|
||||
| Bar, iPhone SE | 267px (40%) | 227px (34%) |
|
||||
| Bar, Pixel 7 | 267px (32%) | 227px (25%) |
|
||||
| Felt height, Galaxy S8 | 192px | 210px |
|
||||
| Felt height, iPhone SE | 243px | 283px |
|
||||
| Worst seat overlap, iPhone SE | 34px | 4px |
|
||||
| Worst seat overlap, Galaxy S8 | 34px | 17px |
|
||||
|
||||
The summary row is held to the same rule as the elision above it, and it is the rule that
|
||||
matters most for anything that hides a number: **it must not lose a chip.** A dealer
|
||||
counting the announcement against the chips in the middle of the table must not find a
|
||||
shortfall the tool invented, so every row count is checked to add up to the pots it
|
||||
stands for, at every table size from three players to twelve.
|
||||
|
||||
### What is left, and why it is a decision rather than a fix
|
||||
|
||||
**315 of 320 audits pass.** The five that don't are all one root cause and all on the two
|
||||
smallest screens in the sweep:
|
||||
|
||||
- `Galaxy S8 · all in` (360x640), twelve players, every one of them all-in at once
|
||||
- `Galaxy S8 · packed · raise pad` (360x640), twelve players with the pad open
|
||||
- `iPhone SE · all in` (375x667), twelve all-in, on the mouse pass only
|
||||
|
||||
The twelve-handed table at 360x640 now passes with the betting live. It fails in the two
|
||||
states that make the bar tallest, and there the arithmetic is simply short: the ring has
|
||||
about 44px of rim per seat and a puck's footprint is 60. No width in the search clears
|
||||
that, which is why `resolveSeatLayout` has a documented fallback instead of pretending —
|
||||
it keeps the narrowest puck on the widest ring and lets this sweep say so out loud.
|
||||
|
||||
The remaining chip offenders are the same shortage seen from the other layer.
|
||||
`anchoredChipTravel` walks a chip back off a stranger's puck a pixel at a time, and stops
|
||||
at its own puck's edge because there is nowhere further to go. When the pucks themselves
|
||||
overlap, travel reaches zero and the chip is still under a neighbour.
|
||||
|
||||
Getting the last five to pass is a design decision and not another measurement: dropping
|
||||
the bet chips at extreme crowding the way the tight tier already drops the hole cards,
|
||||
capping the seat count by viewport, or letting the felt scroll. Each trades something a
|
||||
dealer can see, so none of them should be picked by whoever is next holding a ruler.
|
||||
|
||||
## The contrast check was passing text nobody could read
|
||||
|
||||
The layout sweep has checked WCAG AA contrast for a long time and the page passed it.
|
||||
That result was worth less than it looked, for two separate reasons.
|
||||
|
||||
The first is the instrument. WCAG 2's contrast ratio is a luminance gap, and a luminance
|
||||
gap overstates readability once one of the two colours is near black — which is every
|
||||
reading taken on this page, whose ground is `#141414` and whose panels are darker than
|
||||
its rows. This is not a quibble about the spec; it is the spec's own working group's
|
||||
position, and it is why APCA exists. Tuning a colour until it clears 4.5:1 on a
|
||||
near-black ground lands on text that passes the audit and still reads as a smudge across
|
||||
a table in a dim room. Several rules in the stylesheet said so in their own comments —
|
||||
one of them "measured 4.5:1 at `#a5a5a5` exactly, so it sits above that" — which is the
|
||||
right instinct aimed through the wrong lens.
|
||||
|
||||
The second is that the check could not see half the backgrounds it was scoring against.
|
||||
It reads `background-color` and walks up the ancestors, so a background painted by a
|
||||
gradient is invisible to it; and where several ancestors are candidates it takes *the
|
||||
best* contrast of any of them. Put together, text on the felt was being scored against
|
||||
the dark page behind the felt rather than the felt. Two plain AA failures had been
|
||||
sitting in a clean report for as long as the check has existed: the chair number on an
|
||||
empty seat, on the table's brown rim, at 2.65:1, and the pot's own label, gold on green,
|
||||
at 2.66:1.
|
||||
|
||||
`runContrastAudit.js` is the answer to both. It scores with APCA, and it gets the
|
||||
background by screenshotting the page with every piece of text made transparent and
|
||||
reading the pixels underneath — which cannot be fooled by a gradient, a shadow or a
|
||||
border, because it is looking at what was painted. The first version of it *did* walk the
|
||||
DOM, and reported the gold dealer button as black-on-black at Lc 0.4; an element anyone
|
||||
can see is legible. That false positive is why the tool works the way it does.
|
||||
|
||||
What it found, on 92 distinct pieces of text: 65 below the level APCA sets for the size
|
||||
and weight they are drawn at, **54 of them passing WCAG AA at the same moment**. The
|
||||
worst was Lc 28. After this round nothing on the page is below Lc 60, nothing fails AA,
|
||||
and the worst reading is Lc 60.4.
|
||||
|
||||
Three of the fixes are worth naming, because none of them is "make the grey lighter":
|
||||
|
||||
- **A player's stack was styled as secondary text.** Muted grey at Lc 44, under a name
|
||||
set in `--text` at a heavier weight. Read back the other way that says a dealer needs
|
||||
the name more than the number, and during a hand the opposite is true — the name is
|
||||
looked up once to find the chair, the stack is read on every action because it decides
|
||||
whether a player can cover the bet. Hierarchy now comes from size and weight, which is
|
||||
free, instead of from fading, which on a dark ground is the most expensive way to say
|
||||
"secondary".
|
||||
- **An empty seat had no background of its own**, just a 2%-white wash at 0.7 opacity, so
|
||||
whatever the felt was doing behind it decided how readable its number was: Lc 28 on the
|
||||
rim, 30 on the dark green, 33 further in. Contrast that depends on where a seat happens
|
||||
to sit cannot be reasoned about. It has a ground now. The wash is 0.62 and not the 0.82
|
||||
it was first written at, because contrast turns out to be nearly flat across that range
|
||||
— two points of Lc — and the heavier one put four dark rectangles on the felt like
|
||||
holes.
|
||||
- **The pot's label sat on the one green in the design.** `--gold` is tuned for near-black
|
||||
and measures Lc 70-odd everywhere else it is used; on the pot disc it was Lc 38. The
|
||||
disc is deeper now rather than the label bleached, because gold on dark green is the
|
||||
pairing the whole page is built on. Deepening only the disc's outer stop moved it two
|
||||
points and no further — the highlight is at 32%/28% and that is exactly where the label
|
||||
sits, so the reading was being taken on the brightest part of the disc.
|
||||
|
||||
### What is deliberately not fixed
|
||||
|
||||
APCA asks Lc 90 of anything at 12px/400 or below, and this page is full of 10px and 11px
|
||||
labels that live inside a 58px seat puck. Fifty-five pieces of text are still below that
|
||||
bar. Reaching it means white micro-labels and a flatter, shoutier page; the honest way to
|
||||
clear it is bigger type, and bigger type is a layout change that would relitigate the
|
||||
seat-crowding work above. So the sweep reports two numbers — the strict per-size level,
|
||||
and the Lc 60 / Lc 75 floors this page actually holds itself to — and only the floors are
|
||||
treated as pass/fail.
|
||||
|
||||
## The failures that only happen at the table
|
||||
|
||||
Everything above this line is about a tool being easy to read and quick to press. This
|
||||
section is about it being *there* — the three ways it could let a dealer down on the
|
||||
night that no amount of layout work would have caught, because none of them show up on a
|
||||
developer's desk with a good connection and a plugged-in laptop.
|
||||
|
||||
### One spelling of the URL, because the offline promise depends on it
|
||||
|
||||
The manifest says "Works with no signal", and it did — at `/casino/texasholdem`.
|
||||
|
||||
Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical
|
||||
page. A service worker's scope is not: the spec matches it as a plain string prefix, so a
|
||||
worker scoped to `/casino/` does not control a page at `/Casino/TexasHoldem`. Measured on
|
||||
the capital-C spelling, with the network cut:
|
||||
|
||||
| | registration | controlling | cached | offline |
|
||||
|---|---|---|---|---|
|
||||
| `/casino/texasholdem` | created | yes | 25 entries | loads |
|
||||
| `/Casino/TexasHoldem` | created, active | **no** | **nothing at all** | `ERR_INTERNET_DISCONNECTED` |
|
||||
|
||||
The empty cache is the part that matters, and it took a second look to understand. The
|
||||
registration *succeeded*. The worker *activated*. Nothing was logged. But `registerWorker`
|
||||
handed its precache list to `navigator.serviceWorker.ready`, and `ready` resolves with the
|
||||
registration **controlling this page** — so on a page outside the scope it never resolves,
|
||||
the message is never sent, and not one file is stored. A dealer who had opened that URL to
|
||||
"load the tool for tonight" had loaded nothing, and had no way to find out until the
|
||||
signal went.
|
||||
|
||||
So one spelling wins: a permanent redirect in `Program.cs`, scoped to `/casino` rather
|
||||
than applied site-wide, because every file under `wwwroot/casino` and
|
||||
`wwwroot/images/casino` is already lowercase and so lowercasing a path there cannot miss a
|
||||
real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the
|
||||
site, and `/chessHub` in particular would break.
|
||||
|
||||
The precache handshake was fixed too, rather than left to be saved by the redirect. It
|
||||
now takes the worker off the registration itself — `installing || waiting || active` —
|
||||
and waits for `activated` on that. A tool whose offline support silently depends on being
|
||||
in scope is one URL away from having none.
|
||||
|
||||
### And what the document never mentions
|
||||
|
||||
The shell list is read off the DOM on purpose, so it can't drift from the markup. The
|
||||
icons are the one part of the shell the DOM doesn't mention: the browser reads them out of
|
||||
the manifest, so no tag in the document points at them and the sweep missed all four. An
|
||||
installed app launched with no signal had no icon to show for itself.
|
||||
|
||||
They're read out of the manifest now — not typed into a list here, for exactly the same
|
||||
reason. iOS is the exception: it reads none of the manifest's icons and only
|
||||
`<link rel="apple-touch-icon">`, which *is* in the document, so the sweep picks up the
|
||||
stylesheet, icon and apple-touch links together. Twenty-five entries, all four icons.
|
||||
|
||||
Reading the manifest offline is deliberately cache-first, which is backwards from how the
|
||||
rest of the worker thinks. A plain `fetch` for it failed on every offline launch and
|
||||
logged `ERR_INTERNET_DISCONNECTED` — noise on the one screen where a real error has to be
|
||||
visible, in exchange for re-reading four paths that were already cached.
|
||||
|
||||
### A lock is easy to take and easy to leave holding
|
||||
|
||||
A dealer's phone lies face-up on the felt all night, and dims and locks between the flop
|
||||
and the turn every hand. The Screen Wake Lock API fixes that in about four lines, and the
|
||||
risk runs entirely the other way: a lock that is taken and *not* dropped burns the battery
|
||||
through a break, a re-buy, and twenty minutes of arguing about pizza, with nothing on
|
||||
screen to say why.
|
||||
|
||||
So the rule is narrow — held only while betting is actually open — and the tests are
|
||||
mostly about letting go:
|
||||
|
||||
- **A whole street of renders takes exactly one.** `sync()` runs off `renderAll`, which
|
||||
fires many times a second while a street is being bet.
|
||||
- **Two syncs in the same tick take exactly one.** Without the `pending` guard both get
|
||||
past the "already holding one?" check and the second sentinel is never stored, so
|
||||
nothing can release it.
|
||||
- **A hand that ends inside the request leaves nothing held.** A fold can end the hand
|
||||
between asking for a lock and being handed one, and the sentinel that arrives afterwards
|
||||
belongs to a hand that no longer exists.
|
||||
- **The browser taking it back leaves us able to take it again.** This is not an edge
|
||||
case: the browser releases the lock itself every time the document stops being visible,
|
||||
and never gives it back. A tool that takes one at the start of the hand and assumes it
|
||||
still has it works until the dealer checks a rule on their phone.
|
||||
|
||||
#### A fake that wasn't installed, and four tests that agreed
|
||||
|
||||
The first run of these had eight failures and, worse, one pass: "a browser without the API
|
||||
is left alone entirely" was green from the start.
|
||||
|
||||
`globalThis.navigator = fake` does nothing. Node has had its own `navigator` since v21,
|
||||
defined as a getter, so the assignment silently fails — no error, no effect. Every test in
|
||||
the file was measuring Node's real navigator, which has no `wakeLock`, so the acquire
|
||||
tests failed honestly and the unsupported-browser test passed for precisely the wrong
|
||||
reason. `Object.defineProperty` installs it properly.
|
||||
|
||||
Then five faults were injected and all five were caught: dropping the `pending` guard,
|
||||
keeping a lock the hand no longer wants, ignoring the browser's own release, holding it
|
||||
between hands, and reading a missing `keepScreenAwake` as off instead of on.
|
||||
|
||||
### Asking the browser to keep the night
|
||||
|
||||
"The night lives in this browser and nowhere else" was always true and always missing its
|
||||
second half. `localStorage` is evictable: a browser is free to clear site data when the
|
||||
device runs short, and Safari deletes script-created storage after seven days without a
|
||||
visit. A table saved at last month's game can simply not be there at this one, with
|
||||
nothing having gone wrong and nobody having deleted anything.
|
||||
|
||||
`navigator.storage.persist()` is the way to say this data is not that, and every browser
|
||||
wants the request to come from something the dealer did — so it fires on **Post blinds &
|
||||
deal**, the moment this stops being a setup screen and starts being a night somebody could
|
||||
lose. Chromium and Safari answer silently on their own engagement heuristics; Firefox
|
||||
asks. A no is not an error worth reporting at a table, so the only thing that changes is
|
||||
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// The declarations inside one rule, by its exact selector.
|
||||
const ruleBody = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
if (at < 0) return null;
|
||||
const open = CSS.indexOf("{", at);
|
||||
return CSS.slice(open + 1, CSS.indexOf("}", open));
|
||||
};
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
@@ -96,3 +110,64 @@ test("marking nobody doesn't move a chip", () => {
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
// ---- And the shape of the mark, which is the felt's problem ------------------
|
||||
//
|
||||
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
|
||||
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
|
||||
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
|
||||
//
|
||||
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
|
||||
// height is what the seat ring is short of — at the ends of an ellipse the seats are
|
||||
// separated vertically, so 11px taller is 11px further into the seat next door. The
|
||||
// layout search then tried every width it has, found none that cleared, and fell back
|
||||
// to the narrowest, which is also the one that wraps hardest.
|
||||
//
|
||||
// So the rule every design system gives a badge applies here too: one line, always.
|
||||
describe("the mark is one line, because the ring cannot afford two", () => {
|
||||
test("a stack line can never wrap", () => {
|
||||
const body = ruleBody(".seatPuckStack");
|
||||
assert.ok(body, "the .seatPuckStack rule should exist");
|
||||
assert.match(body, /white-space:\s*nowrap/,
|
||||
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
|
||||
});
|
||||
|
||||
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
|
||||
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
|
||||
// and still doesn't fit paints over the seat beside it instead. The spacing is
|
||||
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
|
||||
// can go — the warm colour and the bold weight stay.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
|
||||
assert.ok(base, "the base all-in rule should exist");
|
||||
assert.ok(tight, "the tight-tier all-in rule should exist");
|
||||
|
||||
const spacingOf = body => {
|
||||
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const wide = spacingOf(base);
|
||||
const narrow = spacingOf(tight);
|
||||
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
|
||||
assert.ok(narrow < wide,
|
||||
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
|
||||
|
||||
// The arithmetic that has to hold for it to fit at all: six characters at 10px
|
||||
// with 4px of padding either side and a 1px border, inside a 44px puck.
|
||||
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
|
||||
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
|
||||
assert.ok(inkAt(narrow) <= CONTENT_PX,
|
||||
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
|
||||
assert.ok(inkAt(wide) > CONTENT_PX,
|
||||
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
|
||||
});
|
||||
|
||||
test("the two signals that survive are the ones doing the work", () => {
|
||||
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
|
||||
// on the narrow pucks is only safe while the colour and the weight remain, so
|
||||
// they are pinned here rather than left to be tidied away later.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
|
||||
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Perceptual contrast (APCA), and how much of it each piece of text needs.
|
||||
//
|
||||
// The audit already had a contrast check and it passed, which is why this exists. That
|
||||
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
|
||||
// instrument for this page: the ratio is a luminance gap, and a luminance gap
|
||||
// systematically *overstates* readability once one of the two colours is near black.
|
||||
// This table's page is #141414 and its panels are darker still, so every reading it
|
||||
// takes is on the side of the algorithm where it is least reliable. Text can clear
|
||||
// 4.5:1 and still read as a smudge.
|
||||
//
|
||||
// APCA asks the other question — can text of *this size and weight* be read on this
|
||||
// background — and answers it on a scale where a given number means the same
|
||||
// readability at either polarity. It replaces nothing: the WCAG check stays, because
|
||||
// that is the number an accessibility audit will ask for. This is the one that says
|
||||
// whether a dealer can read it in a dim room.
|
||||
//
|
||||
// Constants are 0.98G-4g, and the implementation is checked against the eight
|
||||
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
|
||||
// worse than none, because it launders a guess into a number.
|
||||
|
||||
const MAIN_TRC = 2.4;
|
||||
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
|
||||
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
|
||||
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
|
||||
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
|
||||
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
|
||||
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
|
||||
|
||||
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
|
||||
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
|
||||
function sRGBtoY(rgb) {
|
||||
const s = c => Math.pow(c / 255.0, MAIN_TRC);
|
||||
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
|
||||
}
|
||||
|
||||
// Signed: positive is dark text on light, negative is light text on dark. The sign
|
||||
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
|
||||
// differently each way round, which is the whole reason this exists.
|
||||
function contrast(txtY, bgY) {
|
||||
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
|
||||
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
|
||||
|
||||
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
|
||||
|
||||
let sapc, out;
|
||||
if (bgY > txtY) {
|
||||
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
|
||||
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
|
||||
} else {
|
||||
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
|
||||
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
|
||||
}
|
||||
return out * 100;
|
||||
}
|
||||
|
||||
function lc(textRgb, bgRgb) {
|
||||
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
|
||||
}
|
||||
|
||||
// How much contrast this text needs, from the published APCA levels:
|
||||
//
|
||||
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
|
||||
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
|
||||
// Lc 60 the minimum for content text that isn't a column or block
|
||||
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
|
||||
// Lc 30 the absolute floor, and only for genuinely spot-read text
|
||||
//
|
||||
// Interpolating between those anchors rather than inventing a curve. Weight is folded
|
||||
// in as an effective size because that is how the published table behaves: bolder
|
||||
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
|
||||
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
|
||||
function requiredLc(px, weight = 400) {
|
||||
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
|
||||
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
|
||||
|
||||
const anchors = [
|
||||
[12, 90],
|
||||
[14, 90],
|
||||
[15, 82],
|
||||
[18, 75],
|
||||
[24, 60],
|
||||
[36, 45],
|
||||
];
|
||||
if (effective <= anchors[0][0]) return anchors[0][1];
|
||||
const last = anchors[anchors.length - 1];
|
||||
if (effective >= last[0]) return last[1];
|
||||
for (let i = 1; i < anchors.length; i++) {
|
||||
const [x0, y0] = anchors[i - 1];
|
||||
const [x1, y1] = anchors[i];
|
||||
if (effective <= x1) {
|
||||
const t = (effective - x0) / (x1 - x0);
|
||||
return y0 + t * (y1 - y0);
|
||||
}
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
module.exports = { sRGBtoY, contrast, lc, requiredLc };
|
||||
@@ -0,0 +1,141 @@
|
||||
// The contrast arithmetic, checked against the numbers its authors publish.
|
||||
//
|
||||
// A contrast checker that is wrong is worse than not having one, because it launders a
|
||||
// guess into a number and then a stylesheet gets tuned to it. The eight pairs below are
|
||||
// the reference values published with APCA 0.98G-4g; if the implementation drifts from
|
||||
// them by so much as a rounding change, these fail and nothing downstream is believed.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { lc, contrast, sRGBtoY, requiredLc } = require("./apca");
|
||||
|
||||
const hex = h => {
|
||||
h = h.replace("#", "");
|
||||
if (h.length === 3) h = h.split("").map(c => c + c).join("");
|
||||
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||
};
|
||||
|
||||
describe("the published reference pairs", () => {
|
||||
// Text first, background second, exactly as the reference table lists them.
|
||||
const CASES = [
|
||||
["#888", "#fff", 63.056469930209424],
|
||||
["#fff", "#888", -68.54146436644962],
|
||||
["#000", "#aaa", 58.146262578561334],
|
||||
["#aaa", "#000", -56.24113336839742],
|
||||
["#123", "#def", 91.66830811481631],
|
||||
["#def", "#123", -93.06770049484275],
|
||||
["#123", "#444", 8.32326136957393],
|
||||
["#444", "#123", -7.526878460278154],
|
||||
];
|
||||
|
||||
CASES.forEach(([text, bg, expected]) => {
|
||||
test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => {
|
||||
assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9,
|
||||
`got ${lc(hex(text), hex(bg))}, want ${expected}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("polarity", () => {
|
||||
test("is signed, and the sign is not decoration", () => {
|
||||
// Dark text on light is positive, light text on dark negative. The two are not
|
||||
// mirror images — swapping them changes the magnitude as well as the sign,
|
||||
// which is the entire reason this is used instead of a ratio.
|
||||
const dark = lc(hex("#888"), hex("#fff"));
|
||||
const light = lc(hex("#fff"), hex("#888"));
|
||||
assert.ok(dark > 0 && light < 0);
|
||||
assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5,
|
||||
"the same pair reads differently each way round");
|
||||
});
|
||||
|
||||
test("a colour on itself is nothing at all", () => {
|
||||
assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0);
|
||||
});
|
||||
|
||||
test("and so is a difference too small to see", () => {
|
||||
// Below deltaYmin the answer is zero rather than a tiny number, so a rounding
|
||||
// artefact can never be reported as contrast.
|
||||
assert.equal(lc([44, 44, 44], [44, 44, 45]), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("luminance", () => {
|
||||
test("is the straight 2.4 power, not the sRGB piecewise curve", () => {
|
||||
// Worth pinning: the two are close enough to look right and different enough to
|
||||
// move a borderline reading. Mid grey under the piecewise curve is ~0.2159.
|
||||
assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6);
|
||||
});
|
||||
|
||||
test("white is as close to 1 as the published coefficients get", () => {
|
||||
// 1.0000001, not 1. The three coefficients are given to seven places and sum to
|
||||
// a hair over one, and that is the specification rather than a mistake to
|
||||
// correct — the reference pairs above only come out exact with these values.
|
||||
// Asserted loosely on purpose: tightening this to 1e-12 fails on correct code.
|
||||
assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6);
|
||||
assert.equal(sRGBtoY([0, 0, 0]), 0);
|
||||
});
|
||||
|
||||
test("green carries most of it", () => {
|
||||
// Not arbitrary: it's why gold on green was the worst pairing on the felt.
|
||||
assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255]));
|
||||
});
|
||||
|
||||
test("contrast() takes luminances, lc() takes colours", () => {
|
||||
assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("how much contrast a given piece of text needs", () => {
|
||||
test("the published anchors land where they should", () => {
|
||||
assert.equal(Math.round(requiredLc(12, 400)), 90);
|
||||
assert.equal(Math.round(requiredLc(14, 400)), 90);
|
||||
assert.equal(Math.round(requiredLc(18, 400)), 75);
|
||||
assert.equal(Math.round(requiredLc(24, 400)), 60);
|
||||
assert.equal(Math.round(requiredLc(36, 400)), 45);
|
||||
});
|
||||
|
||||
test("smaller text asks for more, never less", () => {
|
||||
for (let px = 9; px < 40; px++) {
|
||||
assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400),
|
||||
`${px}px should ask for at least as much as ${px + 1}px`);
|
||||
}
|
||||
});
|
||||
|
||||
test("weight buys size, at the rate the published table sets", () => {
|
||||
// 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is
|
||||
// what fixes the exchange rate between weight and size here.
|
||||
assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3);
|
||||
assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular");
|
||||
});
|
||||
|
||||
test("it never runs off either end", () => {
|
||||
assert.equal(requiredLc(4, 400), 90);
|
||||
assert.equal(requiredLc(200, 900), 45);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the page's own colours measure", () => {
|
||||
// These are the readings the round was built on. Pinned so that a future change to
|
||||
// the tokens has to look at them rather than move them by accident.
|
||||
const PANEL = hex("#1a1a1a");
|
||||
|
||||
test("the greys these tokens replaced were below the floor", () => {
|
||||
assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey");
|
||||
assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey");
|
||||
});
|
||||
|
||||
test("and the ones that replaced them clear it", () => {
|
||||
assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted");
|
||||
assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim");
|
||||
});
|
||||
|
||||
test("the three tiers are still three tiers", () => {
|
||||
// The worry with lifting muted and dim is that they collapse into --text and the
|
||||
// page flattens. They don't: the spread is wider than it was.
|
||||
const text = Math.abs(lc(hex("#eeeeee"), PANEL));
|
||||
const muted = Math.abs(lc(hex("#c8c8c8"), PANEL));
|
||||
const dim = Math.abs(lc(hex("#b4b4b4"), PANEL));
|
||||
assert.ok(text > muted && muted > dim, "still ordered");
|
||||
assert.ok(text - dim > 30, "and still a real spread");
|
||||
});
|
||||
});
|
||||
@@ -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,15 @@ describe("the tokens themselves", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it.
|
||||
// which is picked per chip from the amount on it, and feedback.js for the
|
||||
// toast's height, which is one line or two depending on what is being said.
|
||||
// The seat sheet's height is the same idea as the action bar's, and for the same
|
||||
// reason: two things fixed to the bottom of a phone, and the toast has to clear
|
||||
// whichever is taller. It changes when a section of the sheet is unfolded, so it
|
||||
// is measured rather than written down — see Render.watchSeatSheetHeight.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
"--toastHeight", "--seatSheetHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,11 @@ const ActionBar = load("actionBar");
|
||||
// is plain state work and testable here. Only render() and open() touch the DOM.
|
||||
const CardPicker = load("cardPicker");
|
||||
|
||||
// The wake lock's rule about when a table needs the screen is plain state work too.
|
||||
// Its acquire/release bookkeeping is testable here as well, against a stand-in for
|
||||
// navigator.wakeLock — see wakeLock.test.js.
|
||||
const WakeLock = load("wakeLock");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -93,6 +98,7 @@ module.exports = {
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -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,100 @@
|
||||
// Just enough PNG to read pixels back out of a screenshot.
|
||||
//
|
||||
// The contrast audit needs to know what colour is actually behind a piece of text, and
|
||||
// the only honest answer is the one the browser painted. Walking the DOM and
|
||||
// compositing `background-color` up the ancestors gets it wrong wherever a gradient,
|
||||
// a shadow or a border is doing the painting: tried that first, and it reported the
|
||||
// gold dealer button as black-on-black at Lc 0.4 — a fault that does not exist, on an
|
||||
// element anyone can see is legible. Sampling the screenshot cannot make that mistake.
|
||||
//
|
||||
// zlib is in Node, so this needs no dependency. Screenshots are 8-bit and
|
||||
// non-interlaced; anything else throws rather than returning quietly wrong pixels.
|
||||
const zlib = require("node:zlib");
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
return pb <= pc ? b : c;
|
||||
}
|
||||
|
||||
function decode(buffer) {
|
||||
if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
|
||||
|
||||
let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
|
||||
const idat = [];
|
||||
|
||||
let at = 8;
|
||||
while (at < buffer.length) {
|
||||
const length = buffer.readUInt32BE(at);
|
||||
const type = buffer.toString("ascii", at + 4, at + 8);
|
||||
const data = buffer.subarray(at + 8, at + 8 + length);
|
||||
at += 12 + length;
|
||||
|
||||
if (type === "IHDR") {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
depth = data[8];
|
||||
colorType = data[9];
|
||||
interlace = data[12];
|
||||
} else if (type === "IDAT") {
|
||||
idat.push(data);
|
||||
} else if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth !== 8) throw new Error(`unsupported bit depth ${depth}`);
|
||||
if (interlace !== 0) throw new Error("interlaced PNG not supported");
|
||||
if (colorType !== 2 && colorType !== 6) throw new Error(`unsupported colour type ${colorType}`);
|
||||
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const raw = zlib.inflateSync(Buffer.concat(idat));
|
||||
const stride = width * channels;
|
||||
const out = Buffer.alloc(width * height * 4);
|
||||
|
||||
let prior = Buffer.alloc(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
||||
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= channels ? line[i - channels] : 0;
|
||||
const up = prior[i];
|
||||
const upLeft = i >= channels ? prior[i - channels] : 0;
|
||||
switch (filter) {
|
||||
case 0: break;
|
||||
case 1: line[i] = (line[i] + left) & 0xff; break;
|
||||
case 2: line[i] = (line[i] + up) & 0xff; break;
|
||||
case 3: line[i] = (line[i] + ((left + up) >> 1)) & 0xff; break;
|
||||
case 4: line[i] = (line[i] + paeth(left, up, upLeft)) & 0xff; break;
|
||||
default: throw new Error(`unknown filter ${filter} on row ${y}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const from = x * channels;
|
||||
const to = (y * width + x) * 4;
|
||||
out[to] = line[from];
|
||||
out[to + 1] = line[from + 1];
|
||||
out[to + 2] = line[from + 2];
|
||||
out[to + 3] = channels === 4 ? line[from + 3] : 255;
|
||||
}
|
||||
prior = line;
|
||||
}
|
||||
|
||||
return { width, height, data: out };
|
||||
}
|
||||
|
||||
// Nearest pixel, clamped. A rect can sit a hair outside the shot after rounding, and
|
||||
// clamping is the right answer there rather than an exception or a black pixel.
|
||||
function pixelAt(image, x, y) {
|
||||
const px = Math.min(image.width - 1, Math.max(0, Math.round(x)));
|
||||
const py = Math.min(image.height - 1, Math.max(0, Math.round(y)));
|
||||
const at = (py * image.width + px) * 4;
|
||||
return [image.data[at], image.data[at + 1], image.data[at + 2]];
|
||||
}
|
||||
|
||||
module.exports = { decode, pixelAt };
|
||||
@@ -0,0 +1,178 @@
|
||||
// The screenshot reader, tested by building PNGs whose pixels are already known.
|
||||
//
|
||||
// This matters more than it looks. If a filter is decoded wrong the pixels are still
|
||||
// plausible colours — slightly off greys and greens, not obvious garbage — and the
|
||||
// contrast audit would report confident, wrong numbers about a page nobody would think
|
||||
// to re-check. So every filter type gets encoded here and read back.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const zlib = require("node:zlib");
|
||||
const { decode, pixelAt } = require("./png");
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, "ascii");
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 0);
|
||||
return Buffer.concat([head, data, crc]);
|
||||
}
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
return pb <= pc ? b : c;
|
||||
}
|
||||
|
||||
// pixels: [y][x] = [r,g,b(,a)]. filter is applied to every row, which is legal PNG and
|
||||
// is exactly how each decoder branch gets exercised.
|
||||
function encode(pixels, filter = 0, channels = 4) {
|
||||
const height = pixels.length, width = pixels[0].length;
|
||||
const stride = width * channels;
|
||||
const rows = [];
|
||||
let prior = Buffer.alloc(stride);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const raw = Buffer.alloc(stride);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
raw[x * channels + c] = pixels[y][x][c] !== undefined ? pixels[y][x][c] : 255;
|
||||
}
|
||||
}
|
||||
const filtered = Buffer.alloc(stride);
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= channels ? raw[i - channels] : 0;
|
||||
const up = prior[i];
|
||||
const upLeft = i >= channels ? prior[i - channels] : 0;
|
||||
switch (filter) {
|
||||
case 0: filtered[i] = raw[i]; break;
|
||||
case 1: filtered[i] = (raw[i] - left) & 0xff; break;
|
||||
case 2: filtered[i] = (raw[i] - up) & 0xff; break;
|
||||
case 3: filtered[i] = (raw[i] - ((left + up) >> 1)) & 0xff; break;
|
||||
case 4: filtered[i] = (raw[i] - paeth(left, up, upLeft)) & 0xff; break;
|
||||
default: throw new Error("bad filter");
|
||||
}
|
||||
}
|
||||
rows.push(Buffer.concat([Buffer.from([filter]), filtered]));
|
||||
prior = raw;
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = channels === 4 ? 6 : 2;
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Deliberately awkward: neighbouring values that differ, so a filter decoded with the
|
||||
// wrong neighbour produces a different answer rather than the same one by luck.
|
||||
const PIXELS = [
|
||||
[[20, 30, 40], [200, 10, 90], [18, 18, 18], [255, 255, 255]],
|
||||
[[0, 0, 0], [28, 108, 60], [108, 68, 39], [1, 2, 3]],
|
||||
[[240, 217, 168], [47, 122, 77], [200, 200, 200], [17, 17, 17]],
|
||||
];
|
||||
|
||||
describe("every filter type reads back exactly", () => {
|
||||
[0, 1, 2, 3, 4].forEach(filter => {
|
||||
test(`filter ${filter}`, () => {
|
||||
const image = decode(encode(PIXELS, filter));
|
||||
assert.equal(image.width, 4);
|
||||
assert.equal(image.height, 3);
|
||||
for (let y = 0; y < PIXELS.length; y++) {
|
||||
for (let x = 0; x < PIXELS[y].length; x++) {
|
||||
assert.deepEqual(pixelAt(image, x, y), PIXELS[y][x],
|
||||
`pixel ${x},${y} under filter ${filter}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("and a three-channel image reads back too", () => {
|
||||
const image = decode(encode(PIXELS, 4, 3));
|
||||
assert.deepEqual(pixelAt(image, 1, 2), [47, 122, 77]);
|
||||
});
|
||||
|
||||
test("including where Paeth has to choose between close neighbours", () => {
|
||||
// Written first to catch paeth(left, up, ...) decoded as paeth(up, left, ...),
|
||||
// on the assumption that the two differ on a tie. They don't, anywhere: the
|
||||
// predictor is symmetric in its first two arguments, checked over all 16,777,216
|
||||
// byte triples. The reason is that a tie between pa and pb requires
|
||||
// left + up == 2 * upLeft, and that is exactly the condition making pc zero — so
|
||||
// every tie is resolved by returning upLeft, whichever order the other two
|
||||
// arrived in. Swapping them is a no-op and no test can catch it, which is worth
|
||||
// knowing rather than papering over with a fixture that appears to.
|
||||
//
|
||||
// What this does catch is the mistake that is actually available: reading the
|
||||
// wrong neighbour. The values sit close together so a predictor that reaches one
|
||||
// pixel off produces a different answer rather than the same one by luck.
|
||||
const CLOSE = [
|
||||
[[100, 10, 20], [110, 40, 25]],
|
||||
[[90, 70, 30], [55, 95, 35]],
|
||||
];
|
||||
const image = decode(encode(CLOSE, 4));
|
||||
assert.deepEqual(pixelAt(image, 1, 1), [55, 95, 35]);
|
||||
assert.deepEqual(pixelAt(image, 0, 1), [90, 70, 30]);
|
||||
assert.deepEqual(pixelAt(image, 1, 0), [110, 40, 25]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampling", () => {
|
||||
const image = decode(encode(PIXELS, 0));
|
||||
|
||||
test("rounds to the nearest pixel", () => {
|
||||
assert.deepEqual(pixelAt(image, 0.4, 0.4), [20, 30, 40]);
|
||||
assert.deepEqual(pixelAt(image, 0.6, 0.4), [200, 10, 90]);
|
||||
});
|
||||
|
||||
test("clamps rather than throwing or reading black", () => {
|
||||
// A rect can sit a hair outside the shot once it's been rounded, and a silent
|
||||
// black pixel there would read as a contrast failure that isn't one.
|
||||
assert.deepEqual(pixelAt(image, -5, -5), [20, 30, 40]);
|
||||
assert.deepEqual(pixelAt(image, 999, 999), [17, 17, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("something that isn't a PNG at all", () => {
|
||||
assert.throws(() => decode(Buffer.from("not a png at all, honestly")), /not a PNG/);
|
||||
});
|
||||
|
||||
test("an interlaced one, rather than returning scrambled pixels", () => {
|
||||
const png = encode(PIXELS, 0);
|
||||
// Byte 12 of the IHDR data: 8 signature + 8 chunk header + 12 = interlace flag.
|
||||
const broken = Buffer.from(png);
|
||||
broken[8 + 8 + 12] = 1;
|
||||
assert.throws(() => decode(broken), /interlaced/);
|
||||
});
|
||||
|
||||
test("a colour type it cannot read", () => {
|
||||
const broken = Buffer.from(encode(PIXELS, 0));
|
||||
broken[8 + 8 + 9] = 3; // palette
|
||||
assert.throws(() => decode(broken), /colour type/);
|
||||
});
|
||||
});
|
||||
@@ -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,229 @@
|
||||
// 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)));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And smaller still once nobody can act -----------------------------------
|
||||
//
|
||||
// The list is decision support. A player working out whether to call is owed the side
|
||||
// pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
//
|
||||
// Once everyone is all-in there is no decision left to support: the only thing that
|
||||
// happens for the rest of the hand is the dealer turning cards. The list went on
|
||||
// standing there anyway, and it is not free — measured at 360x640 with twelve players
|
||||
// all-in, the bar was 267px of a 640px window. That is 42%, against the sweep's own
|
||||
// "persistent chrome under 30%", and the felt pays it on the exact table shape that has
|
||||
// the most seats to fit into what is left.
|
||||
//
|
||||
// So during a run-out it collapses to the main pot and one line for everything else.
|
||||
// The main pot stays because it is the number a dealer says out loud; the layering is
|
||||
// what the pot wizard is for, and the summary row has always said so in as many words.
|
||||
describe("the list during a run-out, when nobody has a decision left", () => {
|
||||
test("is two rows however many people are all-in", () => {
|
||||
const rows = [3, 6, 9, 12].map(n => build(laddered(n), { maxLayers: 4, summarise: true }).length);
|
||||
assert.deepEqual(rows, [2, 2, 2, 2]);
|
||||
});
|
||||
|
||||
test("and that is fewer than the same table gets while the betting is live", () => {
|
||||
// The point of the change, stated as a comparison rather than a constant, so it
|
||||
// still means something if the ordinary cap is ever retuned.
|
||||
const live = build(laddered(12), { maxLayers: 4 }).length;
|
||||
const runout = build(laddered(12), { maxLayers: 4, summarise: true }).length;
|
||||
assert.ok(runout < live, `a run-out should be shorter than live betting (${runout} vs ${live})`);
|
||||
});
|
||||
|
||||
test("still names the main pot, which is the one said out loud", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4, summarise: true });
|
||||
assert.equal(rows[0].label, pots[0].label);
|
||||
assert.equal(rows[0].amount, Money.format(pots[0].amount));
|
||||
});
|
||||
|
||||
test("and still accounts for every chip", () => {
|
||||
// The same rule as the elision above, and the one a summary row must not break:
|
||||
// a dealer counting the announcement against the chips in the middle must not
|
||||
// find a shortfall the tool invented.
|
||||
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, summarise: true })
|
||||
.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("says how many side pots it stands for, and counts them properly at one", () => {
|
||||
const many = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.equal(many[1].label, `${laddered(12).length - 1} side pots`);
|
||||
|
||||
// Three players ladder into two pots: a main and exactly one side.
|
||||
const one = build(laddered(3), { maxLayers: 4, summarise: true });
|
||||
assert.equal(one[1].label, "1 side pot", "a single side pot is not '1 side pots'");
|
||||
});
|
||||
|
||||
test("and points at where the full list actually is", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.match(rows[1].who, /resolve/,
|
||||
"the row that hides the layers has to say where they went");
|
||||
assert.match(rows[1].title, /Side pot/,
|
||||
"and a pointer still gets the full list without resolving anything");
|
||||
});
|
||||
});
|
||||
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
|
||||
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||
});
|
||||
});
|
||||
|
||||
// The pots come out of layers() in one order and with one relationship between them,
|
||||
// and the action bar's wording now depends on both. If eligibility were not nested,
|
||||
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
|
||||
// pot who was never eligible for the main one, and naming the complement would hide
|
||||
// them. It is a property of how the layers are built rather than an accident of the
|
||||
// examples above, so it is checked as one.
|
||||
describe("pot eligibility is nested", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test("every side pot's players are a strict subset of the pot below it", () => {
|
||||
const random = rng(20260905);
|
||||
let multiPotTables = 0;
|
||||
let pairs = 0;
|
||||
|
||||
for (let table = 0; table < 20000; table++) {
|
||||
const n = 2 + Math.floor(random() * 11);
|
||||
const players = Array.from({ length: n }, (_, i) => p(
|
||||
`p${i}`,
|
||||
// A sixth of the table never puts anything in, which is a table mid-deal.
|
||||
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
|
||||
random() < 0.2));
|
||||
|
||||
const layers = PotCalculator.layers(players);
|
||||
if (layers.length > 1) multiPotTables++;
|
||||
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
const below = layers[i - 1].eligible;
|
||||
const belowSet = new Set(below);
|
||||
const here = layers[i].eligible;
|
||||
pairs++;
|
||||
assert.ok(here.every(id => belowSet.has(id)),
|
||||
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
|
||||
JSON.stringify(players));
|
||||
assert.ok(here.length < below.length,
|
||||
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
|
||||
`layers with the same eligibility are supposed to be merged into one pot\n` +
|
||||
JSON.stringify(players));
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the generator rather than the code: a shape that stopped producing side
|
||||
// pots would make every assertion above vacuous and the test would still pass.
|
||||
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
|
||||
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
|
||||
});
|
||||
|
||||
test("so the main pot is everyone still live, and it is the widest", () => {
|
||||
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
|
||||
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
|
||||
layers.slice(1).forEach(layer =>
|
||||
assert.ok(layer.eligible.length < layers[0].eligible.length));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Motion, for the people who asked for less of it.
|
||||
//
|
||||
// The stylesheet had honoured `prefers-reduced-motion` for a while, and all three places
|
||||
// it did were the wrong size of thing: a 2px press-down on a button, a checkbox tick,
|
||||
// and the acting seat's pulse. What none of them covered was motion that moves something
|
||||
// across the screen — and for anyone with a vestibular disorder that is the part that
|
||||
// matters. A button dipping under a thumb is not what makes people ill.
|
||||
//
|
||||
// Four things were doing it. A seat editor that slides the full height of itself up from
|
||||
// off-screen, every time a dealer taps a chair. A toast that slides in from below. And
|
||||
// three `scrollIntoView({ behavior: "smooth" })` calls, which are the most provocative of
|
||||
// the lot, because what moves is the whole viewport rather than one control.
|
||||
//
|
||||
// The scripts are checked here against a stand-in for matchMedia; the stylesheet is
|
||||
// checked against its own text, the way the seat footprint rules already are.
|
||||
const test = require("node:test");
|
||||
const { describe, afterEach } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// ---- The scripts ------------------------------------------------------------
|
||||
|
||||
// Node has no matchMedia at all, so this installs one rather than replacing one. It is
|
||||
// defined as a plain value here for the same reason `navigator` had to be defined with
|
||||
// defineProperty in wakeLock.test.js: assigning to a global the runtime owns can be a
|
||||
// silent no-op, and a test that thinks it installed a fake and didn't passes for the
|
||||
// wrong reason.
|
||||
const setReducedMotion = wanted => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
value: query => ({
|
||||
matches: wanted && /prefers-reduced-motion:\s*reduce/.test(query),
|
||||
media: query,
|
||||
}),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const unsetMatchMedia = () => { delete globalThis.matchMedia; };
|
||||
|
||||
// An element that records what it was asked to do, and nothing else.
|
||||
const fakeElement = () => {
|
||||
const calls = [];
|
||||
return { calls, scrollIntoView(options) { calls.push(options); } };
|
||||
};
|
||||
|
||||
afterEach(unsetMatchMedia);
|
||||
|
||||
describe("what the tool reads off the setting", () => {
|
||||
test("a browser asking for less motion is heard", () => {
|
||||
setReducedMotion(true);
|
||||
assert.equal(Render.prefersReducedMotion(), true);
|
||||
});
|
||||
|
||||
test("and one that isn't, isn't", () => {
|
||||
setReducedMotion(false);
|
||||
assert.equal(Render.prefersReducedMotion(), false);
|
||||
});
|
||||
|
||||
test("a browser too old to have the question is not assumed to be answering it", () => {
|
||||
// No matchMedia at all. The safe reading is "no preference stated", which leaves
|
||||
// the tool behaving exactly as it did — not "reduce", which would quietly take
|
||||
// the animation away from everybody on an old browser.
|
||||
unsetMatchMedia();
|
||||
assert.equal(Render.prefersReducedMotion(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bringing something into view", () => {
|
||||
test("scrolls smoothly when nobody has asked otherwise", () => {
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "center" });
|
||||
assert.deepEqual(el.calls, [{ block: "center", behavior: "smooth" }]);
|
||||
});
|
||||
|
||||
test("and jumps when they have", () => {
|
||||
setReducedMotion(true);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "center" });
|
||||
assert.deepEqual(el.calls, [{ block: "center", behavior: "auto" }]);
|
||||
});
|
||||
|
||||
test("the caller still chooses where it lands, only not how it gets there", () => {
|
||||
// Each of the three callers wants a different alignment — the raise field
|
||||
// centred, the seat sheet nearest, the sidebar section at the top — and the
|
||||
// helper must not flatten that into one answer.
|
||||
setReducedMotion(true);
|
||||
for (const block of ["center", "nearest", "start"]) {
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block });
|
||||
assert.equal(el.calls[0].block, block);
|
||||
}
|
||||
});
|
||||
|
||||
test("a caller that never chose gets a plain scroll rather than an exception", () => {
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el);
|
||||
assert.equal(el.calls[0].behavior, "smooth");
|
||||
});
|
||||
|
||||
test("nothing to scroll is not an error", () => {
|
||||
// main.js reaches for #seatEditorPanel by id and passes whatever it finds.
|
||||
setReducedMotion(true);
|
||||
assert.doesNotThrow(() => Render.bringIntoView(null));
|
||||
assert.doesNotThrow(() => Render.bringIntoView(undefined));
|
||||
assert.doesNotThrow(() => Render.bringIntoView({}));
|
||||
});
|
||||
|
||||
test("the setting is re-read every time, not remembered from page load", () => {
|
||||
// A dealer who turns reduced motion on mid-session should be taken seriously
|
||||
// without reloading the table — and the table is a thing they cannot afford to
|
||||
// reload lightly.
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "start" });
|
||||
|
||||
setReducedMotion(true);
|
||||
Render.bringIntoView(el, { block: "start" });
|
||||
|
||||
assert.deepEqual(el.calls.map(c => c.behavior), ["smooth", "auto"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The stylesheet ---------------------------------------------------------
|
||||
|
||||
// Everything inside the @media block whose condition matches, concatenated — there is
|
||||
// more than one reduced-motion block in the file and they govern different things.
|
||||
const reducedMotionBlocks = (extraCondition = null) => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
const condition = CSS.slice(at + needle.length, open).trim();
|
||||
if (!/prefers-reduced-motion:\s*reduce/.test(condition)) continue;
|
||||
if (extraCondition && !condition.includes(extraCondition)) continue;
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
for (; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) break;
|
||||
}
|
||||
found.push(CSS.slice(open + 1, i));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the stylesheet stops the things that actually travel", () => {
|
||||
test("there is more than one reduced-motion block, and one of them is for the sheet", () => {
|
||||
assert.ok(reducedMotionBlocks().length >= 2,
|
||||
"the press-down block alone was never the whole job");
|
||||
assert.equal(reducedMotionBlocks("max-width").length, 1,
|
||||
"the seat sheet's override has to carry the same width condition as the rule it beats");
|
||||
});
|
||||
|
||||
test("the toast keeps its fade and loses its travel", () => {
|
||||
const all = reducedMotionBlocks().join("\n");
|
||||
const toast = all.slice(all.indexOf(".toast"));
|
||||
assert.match(toast, /transform:\s*none/,
|
||||
"a toast sliding up from the bottom edge is exactly the motion being asked about");
|
||||
// The guidance is not "delete the animation": something that appears already in
|
||||
// place reads as a rendering glitch rather than as news. Opacity survives.
|
||||
assert.match(toast, /transition:\s*opacity/,
|
||||
"the fade has to stay, or the toast stops reading as something that just happened");
|
||||
});
|
||||
|
||||
test("the seat sheet stops sliding, and stops being painted after it has gone", () => {
|
||||
const [sheet] = reducedMotionBlocks("max-width");
|
||||
assert.ok(sheet, "the sheet override should exist");
|
||||
assert.doesNotMatch(sheet, /transition:[^;]*transform/,
|
||||
"the transform transition is the slide, and it is the largest movement here");
|
||||
// The delay is what kept the slide-out on screen for its own quarter second.
|
||||
// With the slide gone it would leave a sheet that has already jumped away still
|
||||
// painted, which is a flicker rather than a courtesy.
|
||||
const flips = sheet.match(/transition:\s*visibility\s+0s[^;]*/g) || [];
|
||||
assert.equal(flips.length, 2,
|
||||
"both directions need saying: the rule that puts the sheet away and the one that opens it");
|
||||
assert.ok(flips.every(rule => /^transition:\s*visibility\s+0s(\s+linear\s+0s)?\s*$/.test(rule.trim())),
|
||||
`visibility has to flip at once in both directions once there is no slide to cover: ${flips}`);
|
||||
assert.doesNotMatch(sheet, /linear\s+0\.\d+s/,
|
||||
"no leftover delay measured against an animation that no longer runs");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And the same rule, applied to whatever the file grows next --------------
|
||||
//
|
||||
// The three checks above name the things that were wrong. This one is the general
|
||||
// version, and it is the one that will still be doing work in a year: it scans the
|
||||
// stylesheet for every `:hover` and `:active` transform there is and insists each one
|
||||
// has an off switch.
|
||||
//
|
||||
// Written because the existing block's membership had gone wrong in a way no
|
||||
// enumerated test would have caught. It held nine `:active` selectors and looked
|
||||
// deliberate, but it had been written to stop a *stuck press state* when a tap turns
|
||||
// into a scroll — reaching for the reduced-motion query as the nearest proxy — and
|
||||
// solving one problem with another's tool is how it came to cover `.dealerChip` and
|
||||
// `.raiseQuickBtn` while missing `.actionBtn`, the class on every button in the action
|
||||
// bar, and every `:hover` in the file.
|
||||
describe("every hover and press in the file has an off switch", () => {
|
||||
// Selectors that move something under the pointer.
|
||||
//
|
||||
// Parsed as blocks rather than scanned line by line, and that distinction is not
|
||||
// pedantry — the line-by-line version was written first and a fault injection walked
|
||||
// straight past it. A rule written on one line puts its selector and its declaration
|
||||
// in the same string, so neither "this line opens a rule" nor "this line is a
|
||||
// transform" matched, and the scan reported a clean file while the lift it was meant
|
||||
// to find sat in plain sight.
|
||||
//
|
||||
// The pattern matches innermost blocks only: a selector cannot contain braces, so an
|
||||
// @media wrapper's body never matches and its contents are reached individually.
|
||||
const pointerTransforms = () => {
|
||||
const withoutComments = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const found = [];
|
||||
for (const [, selector, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const name = selector.trim();
|
||||
if (name.startsWith("@")) continue;
|
||||
if (!name.includes(":hover") && !name.includes(":active")) continue;
|
||||
const moves = body.match(/(^|[;{\s])transform\s*:\s*([^;]+)/);
|
||||
if (!moves || /^none\b/.test(moves[2].trim())) continue;
|
||||
// Split the group. A comma-separated rule is nine separate promises, and
|
||||
// checking the joined string against the reduce block is a test that can only
|
||||
// pass if both lists happen to be written in the same order — which is to say
|
||||
// a test that passes by luck. It also hid three selectors outright: the
|
||||
// line-scanning version kept only the last line of a multi-line group, so
|
||||
// .toastAction, .toastClose and .potWizardChoice were never looked at.
|
||||
for (const one of name.split(",")) {
|
||||
const selector = one.trim().replace(/\s+/g, " ");
|
||||
if (!selector.includes(":hover") && !selector.includes(":active")) continue;
|
||||
found.push({ selector, declaration: moves[2].trim() });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const reduceText = () => reducedMotionBlocks().join("\n");
|
||||
|
||||
test("there are some to check, or this test is measuring nothing", () => {
|
||||
// The guard the accent check taught us to write: a scan that finds nothing
|
||||
// passes just as green as a scan that finds everything covered.
|
||||
assert.ok(pointerTransforms().length >= 4,
|
||||
"the file should still have hover/press transforms in it");
|
||||
});
|
||||
|
||||
test("and every one of them is turned off under reduced motion", () => {
|
||||
const text = reduceText();
|
||||
// A selector counts as covered when it appears in a reduce block, comma-separated
|
||||
// list and all. Compared with whitespace flattened, because the reduce block puts
|
||||
// each selector on its own line and the rule it answers may not.
|
||||
const flat = text.replace(/\s+/g, " ");
|
||||
const missing = pointerTransforms()
|
||||
.filter(rule => !flat.includes(rule.selector))
|
||||
.map(rule => `${rule.selector} { transform: ${rule.declaration} }`);
|
||||
assert.deepEqual(missing, [],
|
||||
`these move under the pointer with no reduced-motion escape:\n ${missing.join("\n ")}`);
|
||||
});
|
||||
|
||||
test("including the one a dealer touches most", () => {
|
||||
// Named on its own as well as caught by the scan, because this is the specific
|
||||
// omission that prompted the scan: Fold, Call, Raise and Post blinds & deal all
|
||||
// carry .actionBtn, and it was the one control missing from a list of nine.
|
||||
assert.match(reduceText(), /\.actionBtn:active/,
|
||||
"the action bar's own buttons are the most-pressed thing in the tool");
|
||||
});
|
||||
|
||||
test("a looping animation only runs where motion is welcome", () => {
|
||||
// The acting seat breathes. An infinite animation is the one kind that cannot be
|
||||
// waited out, so it has to be inside a no-preference block rather than merely
|
||||
// overridden in a reduce one.
|
||||
const noPreference = CSS.indexOf("@media (prefers-reduced-motion: no-preference)");
|
||||
assert.ok(noPreference > 0, "the pulse should sit behind a no-preference query");
|
||||
const infinite = [...CSS.matchAll(/animation:[^;]*infinite/g)].map(m => m.index);
|
||||
assert.ok(infinite.length > 0, "there should still be a looping animation to check");
|
||||
assert.ok(infinite.every(at => at > noPreference),
|
||||
"every looping animation has to sit inside the no-preference block");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// Reads every piece of text on the dealer's page and asks whether it can actually be
|
||||
// read, at the size and weight it is actually drawn.
|
||||
//
|
||||
// node tests/holdem/runContrastAudit.js [baseUrl]
|
||||
//
|
||||
// Needs Playwright and a running site, like runUiAudit.js, and is deliberately kept
|
||||
// out of the unit suite for the same reason.
|
||||
//
|
||||
// Why this exists next to the contrast check already in uiAudit.js: that one measures
|
||||
// WCAG 2 contrast ratio and the page passes it. WCAG 2's ratio is a luminance gap, and
|
||||
// a luminance gap overstates readability once one colour is near black — which is
|
||||
// every reading taken on a page whose background is #141414. APCA asks the question
|
||||
// the ratio can't. Both numbers are reported, because "passes AA, fails APCA" is the
|
||||
// interesting row and hiding either number would hide it.
|
||||
//
|
||||
// How the background is obtained matters more than the arithmetic. The first version
|
||||
// walked the DOM compositing `background-color` up the ancestors, and reported the gold
|
||||
// dealer button as black text on a black background at Lc 0.4 — a fault that does not
|
||||
// exist on an element anyone can see is legible, because a gradient was doing the
|
||||
// painting and `background-color` knew nothing about it. So: paint the page with its
|
||||
// text made transparent, screenshot it, and read the background out of the pixels. The
|
||||
// text's own client rects come from a Range, not the element box, so a label inside a
|
||||
// wide row is sampled where the glyphs are rather than wherever the row happens to end.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { lc, requiredLc } = require("./apca");
|
||||
const { decode, pixelAt } = require("./png");
|
||||
|
||||
const BASE = process.argv[2] || "http://localhost:5199";
|
||||
|
||||
const VIEWPORTS = [
|
||||
[390, 844, "iPhone 14"],
|
||||
[768, 1024, "iPad"],
|
||||
[1280, 900, "desktop"],
|
||||
];
|
||||
|
||||
// WCAG 2 relative luminance — the piecewise curve, which is not the one APCA uses.
|
||||
function wcagLuminance([r, g, b]) {
|
||||
const f = c => {
|
||||
c /= 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
function wcagRatio(fg, bg) {
|
||||
const a = wcagLuminance(fg), b = wcagLuminance(bg);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
function seedTable() {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]].forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
state.handNumber = 5;
|
||||
state.handHistory.push({
|
||||
handNumber: 4, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[0].id, label: "Christopher", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
// Every text node that is actually painted, with the rect its glyphs occupy.
|
||||
function collectText() {
|
||||
const out = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent.trim();
|
||||
if (!text) continue;
|
||||
const el = node.parentElement;
|
||||
if (!el || el.closest("[hidden]")) continue;
|
||||
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
||||
|
||||
// Anything faded is faded onto what is behind it, and the screenshot already
|
||||
// shows that — but fully transparent text is not text.
|
||||
let opacity = 1;
|
||||
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
||||
opacity *= parseFloat(getComputedStyle(n).opacity);
|
||||
}
|
||||
if (opacity < 0.05) continue;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const rects = [...range.getClientRects()].filter(r => r.width > 1 && r.height > 1);
|
||||
if (!rects.length) continue;
|
||||
const r = rects[0];
|
||||
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue;
|
||||
|
||||
const m = cs.color.match(/[\d.]+/g);
|
||||
if (!m) continue;
|
||||
|
||||
out.push({
|
||||
text: text.slice(0, 40),
|
||||
color: [+m[0], +m[1], +m[2]],
|
||||
alpha: m[3] !== undefined ? +m[3] : 1,
|
||||
opacity,
|
||||
px: parseFloat(cs.fontSize),
|
||||
weight: +cs.fontWeight || 400,
|
||||
cls: (el.className && el.className.toString().trim().split(/\s+/)[0]) || el.tagName.toLowerCase(),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
disabled: !!el.closest(":disabled, [disabled], [aria-disabled='true']"),
|
||||
// Sample points along the glyph run, avoiding the very edges where a
|
||||
// border or the next box over would be sampled instead.
|
||||
points: [0.25, 0.5, 0.75].map(t => [r.left + r.width * t, r.top + r.height / 2]),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HIDE_TEXT = `*, *::before, *::after {
|
||||
color: transparent !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-text-stroke-color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
}`;
|
||||
|
||||
async function main() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require("playwright"));
|
||||
} catch {
|
||||
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "holdem-contrast-"));
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/casino/texasholdem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/casino/texasholdem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
// Open every panel, or the sidebar's hint text — the smallest, faintest text on
|
||||
// the page, and so the most likely to be the thing worth finding — is never
|
||||
// measured at all.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll("details").forEach(d => { d.open = true; });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const items = await page.evaluate(collectText);
|
||||
|
||||
const shot = path.join(shotDir, `${width}x${height}.png`);
|
||||
await page.addStyleTag({ content: HIDE_TEXT });
|
||||
await page.waitForTimeout(120);
|
||||
await page.screenshot({ path: shot });
|
||||
// Undo, so the next viewport starts from a page that still has its text.
|
||||
await page.evaluate(() => {
|
||||
const tags = [...document.querySelectorAll("style")];
|
||||
const injected = tags[tags.length - 1];
|
||||
if (injected && injected.textContent.includes("caret-color: transparent")) injected.remove();
|
||||
});
|
||||
|
||||
const image = decode(fs.readFileSync(shot));
|
||||
|
||||
for (const item of items) {
|
||||
// Most common sampled colour along the run: a median would invent a colour
|
||||
// that is on no pixel, and the mode lands on the flat background wherever
|
||||
// there is one.
|
||||
const tally = new Map();
|
||||
for (const [x, y] of item.points) {
|
||||
const key = pixelAt(image, x, y).join(",");
|
||||
tally.set(key, (tally.get(key) || 0) + 1);
|
||||
}
|
||||
const bg = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||
.split(",").map(Number);
|
||||
|
||||
// The text's painted colour: its own alpha, and any inherited opacity, both
|
||||
// land it on the background behind it.
|
||||
const a = item.alpha * item.opacity;
|
||||
const fg = item.color.map((c, i) => c * a + bg[i] * (1 - a));
|
||||
|
||||
const measured = Math.abs(lc(fg, bg));
|
||||
const needed = requiredLc(item.px, item.weight);
|
||||
rows.push({
|
||||
viewport: label,
|
||||
...item,
|
||||
bg, fg: fg.map(Math.round),
|
||||
lc: +measured.toFixed(1),
|
||||
need: +needed.toFixed(0),
|
||||
short: +(needed - measured).toFixed(1),
|
||||
wcag: +wcagRatio(fg, bg).toFixed(2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
fs.rmSync(shotDir, { recursive: true, force: true });
|
||||
|
||||
// One row per distinct piece of text, worst viewport kept.
|
||||
const byKey = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.cls}|${row.px}|${row.weight}|${row.text}`;
|
||||
const prev = byKey.get(key);
|
||||
if (!prev || row.short > prev.short) byKey.set(key, row);
|
||||
}
|
||||
|
||||
const all = [...byKey.values()].sort((a, b) => b.short - a.short);
|
||||
const live = all.filter(r => !r.disabled);
|
||||
const failing = live.filter(r => r.short > 0);
|
||||
const passesAaFailsApca = failing.filter(r => r.wcag >= 4.5);
|
||||
|
||||
// Two numbers, because they answer different questions and only one of them is a
|
||||
// target anyone can hit here.
|
||||
//
|
||||
// The strict count is every piece of text below the published level for the size and
|
||||
// weight it is drawn at. APCA asks Lc 90 of anything at 12px/400 or below, and this
|
||||
// page is full of 10px and 11px labels that live inside a 58px seat puck. Reaching
|
||||
// Lc 90 there means white on black for micro-labels and a flat, shoutier page; the
|
||||
// honest way to clear it is bigger type, and bigger type is a layout change that
|
||||
// belongs to its own round. So the strict count is reported and not chased.
|
||||
//
|
||||
// The floors are what this page holds itself to: nothing below Lc 60, which is the
|
||||
// published minimum for content text that is not a column, and Lc 75 for a column of
|
||||
// body text. Those are checkable and they are the numbers that moved.
|
||||
const FLOOR = 60, BODY_FLOOR = 75;
|
||||
const belowFloor = live.filter(r => r.lc < FLOOR);
|
||||
const belowBody = live.filter(r => r.lc < BODY_FLOOR);
|
||||
const aaFails = live.filter(r => r.wcag < 4.5);
|
||||
const worst = live.reduce((min, r) => Math.min(min, r.lc), Infinity);
|
||||
|
||||
console.log(`${rows.length} readings, ${all.length} distinct pieces of text`);
|
||||
console.log(`worst contrast anywhere on the page: Lc ${worst.toFixed(1)}`);
|
||||
console.log(`below the Lc ${FLOOR} floor: ${belowFloor.length}`);
|
||||
console.log(`below Lc ${BODY_FLOOR}: ${belowBody.length}`);
|
||||
console.log(`still failing WCAG AA 4.5:1: ${aaFails.length}`);
|
||||
console.log(`below the strict APCA level for their size/weight: ${failing.length}`
|
||||
+ ` (${passesAaFailsApca.length} of them pass AA)\n`);
|
||||
|
||||
if (belowFloor.length) {
|
||||
console.log(`=== below the Lc ${FLOOR} floor ===`);
|
||||
belowFloor.sort((a, b) => a.lc - b.lc).forEach(r =>
|
||||
console.log(` Lc ${String(r.lc).padStart(5)} ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
||||
console.log("");
|
||||
}
|
||||
if (aaFails.length) {
|
||||
console.log("=== still failing WCAG AA ===");
|
||||
aaFails.sort((a, b) => a.wcag - b.wcag).forEach(r =>
|
||||
console.log(` ${String(r.wcag).padStart(5)}:1 ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const show = r => ` Lc ${String(r.lc).padStart(5)} (needs ${String(r.need).padStart(2)},`
|
||||
+ ` short ${String(r.short).padStart(5)}) WCAG ${String(r.wcag).padStart(5)}:1`
|
||||
+ ` ${r.px}px/${r.weight} ${r.cls.padEnd(22)} rgb(${r.fg.join(",")}) on rgb(${r.bg.join(",")})`
|
||||
+ ` "${r.text}"`;
|
||||
|
||||
console.log("=== worst 30 ===");
|
||||
failing.slice(0, 30).forEach(r => console.log(show(r)));
|
||||
|
||||
const byClass = new Map();
|
||||
failing.forEach(r => {
|
||||
const cur = byClass.get(r.cls);
|
||||
if (!cur || r.short > cur.short) byClass.set(r.cls, r);
|
||||
});
|
||||
console.log(`\n=== ${byClass.size} distinct classes below level ===`);
|
||||
[...byClass.values()].sort((a, b) => b.short - a.short)
|
||||
.forEach(r => console.log(` ${String(r.short).padStart(5)} short ${r.px}px/${r.weight} ${r.cls}`));
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -40,12 +40,21 @@ const OVERLAYS = [
|
||||
// sitting down more than on anything a viewport can tell you. A full table is where the
|
||||
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
|
||||
// of fifty-four tables had one player's name or stack painted under another.
|
||||
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
|
||||
|
||||
const TABLES = [
|
||||
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], false],
|
||||
["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]],
|
||||
["packed", 12, PACKED],
|
||||
// The same twelve, every one of them all-in for a different amount. This is the
|
||||
// shape that makes a pot layer per player, and the sweep had never reached it:
|
||||
// every table here was driven with Call and Check, so the side-pot breakdown — the
|
||||
// one thing on the bar whose height depends on the table — was empty at all 294
|
||||
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
|
||||
// left 96px of felt for twelve seats.
|
||||
["all in", 12, PACKED, false, 12],
|
||||
// The half-full table again, with the board turned on and five cards on it. The
|
||||
// board is furniture in the middle of the felt that nothing else was ever measured
|
||||
// against — and where the middle cannot hold it, it moves to a strip above the
|
||||
@@ -116,7 +125,7 @@ async function main() {
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
for (const [shape, seats, players, board] of TABLES) {
|
||||
for (const [shape, seats, players, board, shoves] of TABLES) {
|
||||
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
@@ -133,7 +142,36 @@ async function main() {
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.waitForTimeout(220);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Everyone's whole stack in, which is what stacks up the side pots.
|
||||
// There is no one-tap shove: a player who can't cover the bet gets
|
||||
// "Call all-in", and anyone else has to open the raise pad, take its
|
||||
// All-in preset and confirm.
|
||||
for (let i = 0; i < (shoves || 0); i++) {
|
||||
const step = await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const short = find(/^Call all-in/);
|
||||
if (short) { short.click(); return "done"; }
|
||||
const open = find(/^Raise|^Bet/);
|
||||
if (!open) return null;
|
||||
open.click();
|
||||
return "pad";
|
||||
});
|
||||
if (!step) break;
|
||||
await page.waitForTimeout(120);
|
||||
if (step === "pad") {
|
||||
await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const max = find(/^All-in/);
|
||||
if (max) max.click();
|
||||
const go = find(/^Raise to|^Bet/);
|
||||
if (go) go.click();
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < (shoves ? 0 : 3); i++) {
|
||||
const acted = await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
||||
@@ -166,6 +204,40 @@ async function main() {
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
// The raise pad, which is part of the bar rather than an overlay and so
|
||||
// was never once measured by any of this. It is the tallest thing the
|
||||
// bar ever holds and the only place in the tool a dealer types an
|
||||
// amount, and it had a 36px-tall field on every phone in the list: the
|
||||
// `hover: none` rule that sizes fields for a thumb names the settings
|
||||
// panels and the seat form, and the one field on the thumb surface was
|
||||
// not in the list. Nothing here could see that, because nothing here
|
||||
// ever opened it.
|
||||
if (await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
})) {
|
||||
await page.waitForTimeout(200);
|
||||
checks++;
|
||||
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
||||
occlusionSelector: "#actionBar button, #actionBar input",
|
||||
}));
|
||||
if (!pad.clean) {
|
||||
failures.push({
|
||||
where: `raise pad · ${label} ${width}x${height}`,
|
||||
offenders: pad.failed.flatMap(f => f.offenders),
|
||||
});
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const cancel = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Cancel/.test(b.textContent.trim()));
|
||||
if (cancel) cancel.click();
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// The overlays don't change with the table's shape, so they are only
|
||||
// opened over the half-full one — twice the viewports is enough time
|
||||
// spent already.
|
||||
|
||||
@@ -165,10 +165,62 @@ describe("what the stylesheet has to keep holding up", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the blind badge counts", () => {
|
||||
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
|
||||
// off the puck and always points away from the table — `top: -9px` on the upper arc,
|
||||
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
|
||||
// them back to back and it costs nothing. That much is true.
|
||||
//
|
||||
// What was also claimed is that two seats on the *same* arc "wear them at the same
|
||||
// height, offset by exactly as much as the pucks are". True of a straight rail,
|
||||
// false of a ring: neighbours on a curve sit at different heights, so the badge
|
||||
// pointing away from the table on one of them points at the corner of the other.
|
||||
// Measured on the running page, three tables in the sweep had a player's stack
|
||||
// painted under the next player's SB or BB — and the badge outranks everything else
|
||||
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
|
||||
test("a seat needs room for the badge it might be wearing", () => {
|
||||
const puck = { width: 64, height: 40 };
|
||||
assert.deepEqual(Render.seatFootprint(puck),
|
||||
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
|
||||
});
|
||||
|
||||
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
|
||||
// It sits inside the puck's own width — see "all three sit in the same place"
|
||||
// above, which holds the stylesheet to it.
|
||||
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
|
||||
});
|
||||
|
||||
test("the number here is the number the stylesheet draws", () => {
|
||||
// Two files have to agree about one distance: holdem.css hangs the badge off the
|
||||
// puck by it, render.js charges every chair in the ring for it. They are wired
|
||||
// together by nothing but this test. Drift either way is silent — too small and
|
||||
// badges land on neighbours again, too large and every puck on a nine-handed
|
||||
// phone gives up a size step for clearance nothing needs.
|
||||
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
||||
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
||||
assert.ok(top && bottom, "the badge should hang off both arcs");
|
||||
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
|
||||
"the same distance at both ends, or one arc is charged for the other's badge");
|
||||
});
|
||||
|
||||
test("and it is counted once, not once per seat", () => {
|
||||
// seatsCollide is given one box and compares centres against it, so the
|
||||
// separation it asks for is one puck plus one badge. That is exactly right for
|
||||
// two seats on the same arc each wearing one, and generous for the far commoner
|
||||
// case where only one of the two is in the blinds at all. Counting it on both
|
||||
// sides is what made a landscape table unsatisfiable the first time this was
|
||||
// tried, back when that felt was 87px tall.
|
||||
const puck = { width: 64, height: 40 };
|
||||
const grew = Render.seatFootprint(puck).height - puck.height;
|
||||
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no browser to measure", () => {
|
||||
test("nothing here throws", () => {
|
||||
assert.equal(Render.measuredSeatBox(), null);
|
||||
assert.equal(Render.seatFootprint(null), null);
|
||||
assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,234 @@
|
||||
// What the seat sheet shows before it is asked.
|
||||
//
|
||||
// The sheet is the second-most-used surface in the tool — every buy-in, every rename,
|
||||
// every correction goes through it — and it was the least finished. Seven things were
|
||||
// stacked flat at one weight and all open at once: a name, two status boxes, two
|
||||
// hole-card slots, a stack, a five-box chip counter with an override and a total, and a
|
||||
// list of custom fields. Measured on a 390px phone it came to 594px, and the three
|
||||
// controls a dealer touches on nearly every visit were 132px of that — 22%. The chip
|
||||
// counter alone was 174px, the tallest single thing in the sheet, and its five number
|
||||
// boxes were told apart by five coloured dots and a `title` attribute, which is a
|
||||
// tooltip, which is nothing at all on a phone.
|
||||
//
|
||||
// Two of these describe layout decisions and are checked as text — the markup for what
|
||||
// sits in front of a dealer at rest, the stylesheet for the scrim and the order two
|
||||
// bottom-anchored things stack in. The third describes behaviour and is run: which
|
||||
// sections stand open is a question about the seat, and the answer is computed.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const template = id => {
|
||||
const at = PAGE.indexOf(`<template id="${id}">`);
|
||||
assert.ok(at >= 0, `there is no <template id="${id}">`);
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the sheet puts in front of a dealer at rest", () => {
|
||||
const seatForm = () => template("seatEditorTemplate");
|
||||
|
||||
// Everything the form holds that is *not* inside one of the folded sections. The
|
||||
// <details> blocks are stripped whole, so what is left is the flat part.
|
||||
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
|
||||
|
||||
test("the flat part is the name, the two status boxes and the stack, and nothing else", () => {
|
||||
const flat = alwaysVisible();
|
||||
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
|
||||
.forEach(cls => assert.ok(flat.includes(cls),
|
||||
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
||||
|
||||
// And the four that used to be up here with them. Each is a job most tables do
|
||||
// once a night or never, and between them they were two thirds of the sheet.
|
||||
["cardSlot", "chipCounts", "amountOverrideInput", "customFields", "addFieldBtn"]
|
||||
.forEach(cls => assert.ok(!flat.includes(cls),
|
||||
`${cls} is open at rest again — it belongs inside a folded section`));
|
||||
});
|
||||
|
||||
test("every folded section says what it holds and what is in it", () => {
|
||||
const sections = [...seatForm().matchAll(/<details class="([^"]*seatSubPanel[^"]*)">([\s\S]*?)<\/details>/g)];
|
||||
assert.equal(sections.length, 3, "the sheet folds cards, contribution and notes");
|
||||
|
||||
sections.forEach(([, className, markup]) => {
|
||||
const summary = markup.match(/<summary>([\s\S]*?)<\/summary>/);
|
||||
assert.ok(summary, `${className} has no summary to press`);
|
||||
// A name a dealer would recognise, and a place for the one fact that says
|
||||
// whether opening it is worth the tap.
|
||||
assert.match(summary[1], /<span class="seatSubPanelName">[^<]+<\/span>/,
|
||||
`${className}'s summary has no name in it`);
|
||||
assert.match(summary[1], /class="seatGlance/,
|
||||
`${className} says nothing while it is shut`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the chip counter's values are text, not a tooltip", () => {
|
||||
// Five identical number boxes, each worth a different amount, told apart by a
|
||||
// coloured dot and a `title`. A title is a hover, and there is no hover here.
|
||||
// It also meant the wrapping <label> had no text in it, so the boxes had no
|
||||
// accessible name either — five unnamed spinbuttons in a row.
|
||||
assert.match(template("chipCountTemplate"), /class="chipCountValue"/,
|
||||
"the chip's value has to be in the row, where it can be read and announced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet is in front of the table, and says so", () => {
|
||||
test("there is a scrim, and it is furniture the desktop panel does not get", () => {
|
||||
assert.match(PAGE, /id="seatSheetScrim"/, "the sheet has nothing behind it");
|
||||
// Above 700px this element is not a scrim for anything — the seat form is a
|
||||
// panel in the sidebar column, with the whole page still live around it.
|
||||
const off = CSS.match(/\.seatSheetGrip,[\s\S]{0,120}?#seatSheetScrim\s*\{\s*display:\s*none/);
|
||||
assert.ok(off, "the scrim has to be off by default, with the sheet's other furniture");
|
||||
});
|
||||
|
||||
test("and it is not called an Overlay, because Modal would adopt it", () => {
|
||||
// Modal.mount() watches every id ending in "Overlay" and makes a dialog of it.
|
||||
// This one is a sidebar panel two thirds of the time.
|
||||
assert.ok(!/id="seatSheet[A-Za-z]*Overlay"/.test(PAGE));
|
||||
});
|
||||
|
||||
test("and it is put away with visibility, not with opacity", () => {
|
||||
// The same lesson the sheet above it already learned about transforms, and the
|
||||
// layout sweep caught it the same way: a fixed, full-window box at `opacity: 0`
|
||||
// is invisible and still entirely there. bottomFurniture() read it as furniture
|
||||
// standing 640px tall at the bottom of a 640px phone, in every state and on
|
||||
// every screen, and asked scroll-padding to reserve the whole window for it.
|
||||
const phone = CSS.slice(CSS.indexOf("@media (max-width: 700px)"));
|
||||
const rule = phone.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the phone block should make a scrim of it");
|
||||
assert.match(rule[0], /visibility:\s*hidden/,
|
||||
"opacity alone leaves a full-window fixed element in the layout and the a11y tree");
|
||||
});
|
||||
|
||||
test("the scrim stops where the sheet stops when a phone is on its side", () => {
|
||||
// Fold, Call and Raise are a rail down the right-hand edge in landscape, and the
|
||||
// sheet is deliberately kept clear of it. A scrim that reached across would dim
|
||||
// the one part of the screen the sheet was making room for — and swallow its
|
||||
// taps, which is worse.
|
||||
const landscape = CSS.slice(CSS.indexOf("@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px)"));
|
||||
const rule = landscape.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the landscape block never tells the scrim about the rail");
|
||||
assert.match(rule[0], /right:\s*var\(--actionRailW\)/);
|
||||
});
|
||||
|
||||
test("the toast steps over the sheet, and the rule that says so comes last", () => {
|
||||
// Two things are fixed to the bottom of a phone and the toast is the one that
|
||||
// gives way. `body.hasActionBar #toastHost` and `body.seatSheetOpen #toastHost`
|
||||
// have identical specificity, so this is decided by source order and by nothing
|
||||
// else — a copy written above the other would look right and do nothing.
|
||||
const bar = CSS.indexOf("body.hasActionBar #toastHost");
|
||||
const sheet = CSS.indexOf("body.seatSheetOpen #toastHost");
|
||||
assert.ok(bar > 0 && sheet > 0, "both rules should exist");
|
||||
assert.ok(sheet > bar,
|
||||
"at equal specificity the later rule wins, and the sheet is the taller of the two");
|
||||
// Measured, not assumed: the height changes the moment a section is unfolded.
|
||||
assert.match(CSS.slice(sheet, sheet + 240), /var\(--seatSheetHeight/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The form the disclosures are rendered into, reduced to the two things
|
||||
// renderSeatDisclosures asks of it: find a section, find its glance.
|
||||
const fakeForm = () => {
|
||||
const sections = {};
|
||||
for (const name of ["cardsPanel", "contributionPanel", "notesPanel"]) {
|
||||
sections[`.${name}`] = {
|
||||
open: false,
|
||||
glance: null,
|
||||
querySelector(selector) {
|
||||
assert.equal(selector, ".seatGlance");
|
||||
return { set textContent(text) { sections[`.${name}`].glance = text; } };
|
||||
},
|
||||
};
|
||||
}
|
||||
return { querySelector: selector => sections[selector] || null, sections };
|
||||
};
|
||||
|
||||
const disclose = (state, player) => {
|
||||
const form = fakeForm();
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, player);
|
||||
return form.sections;
|
||||
};
|
||||
|
||||
describe("which sections stand open is a question about the seat", () => {
|
||||
test("a seat with nothing on it opens nothing", () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.deepEqual(
|
||||
Object.values(sections).map(s => s.open), [false, false, false],
|
||||
"an empty seat's sheet is three lines and the three fields above them");
|
||||
});
|
||||
|
||||
test("a seat holding cards opens its cards, and names them while shut", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].cards = ["as", "kd"];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".cardsPanel"].open, true);
|
||||
// Whatever formatCard makes of them — the point is that both are in it.
|
||||
assert.match(sections[".cardsPanel"].glance, /A/);
|
||||
assert.match(sections[".cardsPanel"].glance, /K/);
|
||||
});
|
||||
|
||||
test("a table that asked to be shown the cards opens them before the first one", () => {
|
||||
// Every seat at such a table is about to have two. Opening the slots after the
|
||||
// cards arrive would be opening them one tap too late, every hand.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.trackBoard = true;
|
||||
assert.equal(disclose(state, state.seats[0])[".cardsPanel"].open, true);
|
||||
});
|
||||
|
||||
test("a seat with chips counted opens the count, with the total on the row", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const fiver = state.chipLegend.find(chip => chip.value === 5);
|
||||
state.seats[0].chipCounts = { [fiver.id]: 4 };
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".contributionPanel"].open, true);
|
||||
assert.equal(sections[".contributionPanel"].glance, "$20");
|
||||
});
|
||||
|
||||
test("a typed amount counts as something on the seat too", () => {
|
||||
// Overriding the total is the other way of answering the same question, and a
|
||||
// dealer who typed a number has to be able to see it without hunting.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 60;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true);
|
||||
});
|
||||
|
||||
test("and a zero someone actually typed is not the same as never having typed one", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 0;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true,
|
||||
"0 is a value; only null means the field was left alone");
|
||||
});
|
||||
|
||||
test("a seat with a note on it opens the notes and counts them", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].customFields = [{ label: "Owes", value: "$40" }, { label: "", value: "" }];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".notesPanel"].open, true);
|
||||
assert.equal(sections[".notesPanel"].glance, "1 note",
|
||||
"an empty row the dealer has not filled in yet is not a note");
|
||||
});
|
||||
|
||||
test("a section is only ever pushed open, never pulled shut", () => {
|
||||
// This runs again after every keystroke in the sheet. A dealer who opened the
|
||||
// chip counter to start counting would have it shut under them on the first
|
||||
// digit if this ever wrote `open = false`.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const form = fakeForm();
|
||||
form.sections[".notesPanel"].open = true;
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, state.seats[0]);
|
||||
assert.equal(form.sections[".notesPanel"].open, true);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// Which fields replace their contents when a dealer reaches for them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
@@ -51,3 +52,68 @@ test("nothing at all is not a field", () => {
|
||||
test("a field with no type stated is a text box, so it does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
|
||||
});
|
||||
|
||||
// ---- A scroll is not an edit ------------------------------------------------
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and the browser lets the mouse
|
||||
// wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus
|
||||
// the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500
|
||||
// becomes $501. Both go straight through to the saved table, because these fields save
|
||||
// on `input` like everything else here.
|
||||
//
|
||||
// Nothing is announced and nothing looks wrong. The tool has invented a chip, and the
|
||||
// gesture that did it was a dealer reaching for another panel.
|
||||
//
|
||||
// The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate —
|
||||
// the arrow keys, the stepper arrows, typing — and none of them is touched. Scrolling is
|
||||
// the one actuation nobody means.
|
||||
describe("a wheel over a field the dealer is in", () => {
|
||||
const box = (type, tag = "INPUT") => ({ tagName: tag, type });
|
||||
|
||||
test("a focused number box would be edited by it", () => {
|
||||
const stack = box("number");
|
||||
assert.equal(Money.wheelWouldEdit(stack, stack), true);
|
||||
});
|
||||
|
||||
test("the same box, not focused, would not", () => {
|
||||
// An unfocused spinbutton ignores the wheel. There is nothing to defend, and
|
||||
// blurring something that is not focused is a no-op with a side effect.
|
||||
const stack = box("number");
|
||||
assert.equal(Money.wheelWouldEdit(stack, null), false);
|
||||
assert.equal(Money.wheelWouldEdit(stack, box("number")), false);
|
||||
});
|
||||
|
||||
test("the amount boxes that already moved off type=number are safe as they are", () => {
|
||||
// Add chips, the raise pad and Quick Split use text + inputmode="decimal", which
|
||||
// is not a spinbutton and cannot be scrolled into a different number.
|
||||
const amount = { tagName: "INPUT", type: "text", inputMode: "decimal" };
|
||||
assert.equal(Money.wheelWouldEdit(amount, amount), false);
|
||||
// Still an amount field for the other rule, though — the two questions are
|
||||
// different and this one must not quietly answer both.
|
||||
assert.equal(Money.selectsOnFocus(amount), true);
|
||||
});
|
||||
|
||||
test("a name, a note and a table are left alone", () => {
|
||||
const name = { tagName: "INPUT", type: "text" };
|
||||
const notes = { tagName: "TEXTAREA", type: "textarea" };
|
||||
assert.equal(Money.wheelWouldEdit(name, name), false);
|
||||
assert.equal(Money.wheelWouldEdit(notes, notes), false);
|
||||
assert.equal(Money.wheelWouldEdit(null, null), false);
|
||||
assert.equal(Money.wheelWouldEdit(undefined, undefined), false);
|
||||
});
|
||||
|
||||
test("the type is read case-insensitively, the way an attribute comes back", () => {
|
||||
const shouty = box("NUMBER");
|
||||
assert.equal(Money.wheelWouldEdit(shouty, shouty), true);
|
||||
});
|
||||
|
||||
test("a select is a spinbutton to nobody", () => {
|
||||
// The page has one <select> — which way the blinds move — and it was the obvious
|
||||
// next suspect. It isn't one: focused, hovered and scrolled 300px in Chromium it
|
||||
// stays on "clockwise", because a select only takes the wheel while its dropdown
|
||||
// is open. Checked rather than assumed, and left out rather than defended
|
||||
// against, so this rule keeps meaning exactly one thing.
|
||||
const select = { tagName: "SELECT", type: "select-one" };
|
||||
assert.equal(Money.wheelWouldEdit(select, select), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Who the tool asks to turn over, and in what order.
|
||||
//
|
||||
// The board half of the card feature always worked: every street, a sheet asks which
|
||||
// cards came off the deck. The hands half never did. A dealer entered five community
|
||||
// cards, pressed the showdown button, and the pot wizard told them "Chris, Mike and
|
||||
// Alex haven't turned over, so who won is still yours to say" — with nothing in the
|
||||
// sheet to turn anybody over with. The only way in was to close the wizard, tap a seat
|
||||
// puck, find the card slots in the seat editor, and repeat it for each player, with the
|
||||
// pot sitting on the table.
|
||||
//
|
||||
// So the showdown button now asks, the same way the street button does. This file is
|
||||
// the list it asks from. The sheet itself is DOM and is checked in the browser; the
|
||||
// question of who is on the list, and in which order, is state and lives here.
|
||||
//
|
||||
// Three rules hold it together:
|
||||
//
|
||||
// Only when asked for. The setting is off by default and the list is empty when it
|
||||
// is off, so a table that never wanted this never sees a sheet.
|
||||
//
|
||||
// Only what isn't known. A hand already entered is an answer already given, and
|
||||
// asking again offers to overwrite it.
|
||||
//
|
||||
// In the order the cards actually come over — the showdown order the action bar
|
||||
// already announces, then round the table from there. A dealer looking at the seat
|
||||
// the bar just named should not be asked about the one behind them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A hand played to the river with everyone still in, which is the only shape that
|
||||
// has a showdown to ask about.
|
||||
const atShowdown = ({ seats = 4, tracking = true } = {}) => {
|
||||
const state = makeTable({ seatCount: seats, stack: 500, smallBlind: 5, bigBlind: 10 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "river";
|
||||
state.board = ["2C", "7D", "9S", "JH", "3S"];
|
||||
state.trackBoard = tracking;
|
||||
state.seats.forEach(player => { player.hasActed = true; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const queue = state => Render.showdownCardQueue(state);
|
||||
|
||||
describe("only when the dealer asked to be asked", () => {
|
||||
test("the setting off means nobody is asked anything", () => {
|
||||
assert.deepStrictEqual(queue(atShowdown({ tracking: false })), []);
|
||||
});
|
||||
|
||||
test("the setting on asks everyone still in", () => {
|
||||
assert.strictEqual(queue(atShowdown()).length, 4);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at is not a showdown", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.inHand = false; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a hand won without a showdown", () => {
|
||||
// Everyone else folded, so there is one hand left and it never comes over. The
|
||||
// bar says "Award pot to Chris" here, not "Showdown", and asking Chris to turn
|
||||
// over a hand nobody paid to see is asking for a card the tool will never use.
|
||||
test("one player left is asked for nothing", () => {
|
||||
const state = atShowdown();
|
||||
[1, 2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
|
||||
test("two left is a showdown, however it was arrived at", () => {
|
||||
const state = atShowdown();
|
||||
[2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.strictEqual(queue(state).length, 2);
|
||||
});
|
||||
|
||||
// Everybody all-in on the flop is still a showdown: the cards go on their backs,
|
||||
// the board runs out, and the hands are read exactly as they would have been.
|
||||
test("all-in with the betting finished is still asked about", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.stack = 0; });
|
||||
assert.ok(!Betting.canStillBet(state));
|
||||
assert.strictEqual(queue(state).length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("only the hands it doesn't already have", () => {
|
||||
test("a hand already entered is not asked for again", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", "AH"];
|
||||
assert.ok(!queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("half a hand entered is still asked for", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", ""];
|
||||
assert.ok(queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("every hand entered means the sheet never opens", () => {
|
||||
const state = atShowdown();
|
||||
const hands = [["AS", "AH"], ["KS", "KH"], ["QS", "QH"], ["9H", "9D"]];
|
||||
state.seats.forEach((player, i) => { player.cards = hands[i]; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the order the cards come over", () => {
|
||||
// The bar already says "Mike shows first — last to bet or raise" out loud. The
|
||||
// sheet asking about somebody else first would be the tool contradicting itself
|
||||
// in the same breath.
|
||||
test("it starts with whoever the bar says shows first", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.strictEqual(Betting.showdownOrder(state).index, 2);
|
||||
assert.strictEqual(queue(state)[0], 2);
|
||||
});
|
||||
|
||||
test("and then goes round the table from there", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.deepStrictEqual(queue(state), [2, 3, 0, 1]);
|
||||
});
|
||||
|
||||
test("nobody checked the river, so it starts left of the button", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
const first = Betting.showdownOrder(state);
|
||||
assert.strictEqual(first.reason, "leftOfButton");
|
||||
assert.strictEqual(queue(state)[0], first.index);
|
||||
});
|
||||
|
||||
test("a player already entered drops out of the order without moving the rest", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
state.seats[3].cards = ["KS", "KH"];
|
||||
assert.deepStrictEqual(queue(state), [2, 0, 1]);
|
||||
});
|
||||
|
||||
test("everyone still in is asked exactly once", () => {
|
||||
const state = atShowdown({ seats: 9 });
|
||||
const asked = queue(state);
|
||||
assert.strictEqual(new Set(asked).size, asked.length);
|
||||
assert.strictEqual(asked.length, Betting.liveSeats(state).length);
|
||||
});
|
||||
});
|
||||
@@ -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,175 @@
|
||||
// What stands between the top of a phone and the felt.
|
||||
//
|
||||
// The header was 120px of a 390x844 phone — 16% of the window, spent before the table
|
||||
// started — and only 46 of that was a control. The other 74 were a 22px page title with
|
||||
// a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once,
|
||||
// above a screen whose subject is the green table underneath.
|
||||
//
|
||||
// A phone held sideways had already solved this. The landscape block clips the title and
|
||||
// puts everything on one row, because at 390px tall there was no other option. Upright
|
||||
// never got the same answer, and upright is how this tool is held: measured after, the
|
||||
// header is 44px and the felt went from 496px to 570.
|
||||
//
|
||||
// Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's
|
||||
// and the reduced-motion rules are — because "which row is this on" is a layout decision
|
||||
// that lives in the file. And the action bar's, by running it: the hand number had to go
|
||||
// somewhere, and where it went is a rendering question.
|
||||
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, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// Every @media block written at exactly this condition, concatenated.
|
||||
//
|
||||
// All of them, not the first one — `@media (max-width: 700px)` is opened twice in this
|
||||
// file, the second time to hold the phone layout proper, and a helper that stopped at
|
||||
// the first found a block with none of these rules in it and reported them missing.
|
||||
const mediaBlock = condition => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
|
||||
let depth = 0;
|
||||
for (let i = open; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
|
||||
return found.join("\n");
|
||||
};
|
||||
|
||||
const ruleIn = (block, selector) => {
|
||||
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
|
||||
const found = block.match(re);
|
||||
return found ? found[2] : null;
|
||||
};
|
||||
|
||||
const PHONE = () => mediaBlock("(max-width: 700px)");
|
||||
// The header's sideways rules are in the plain landscape query, not the one that also
|
||||
// caps the width — that later block is the seat sheet's and the action rail's. Both
|
||||
// exist, and picking the wrong one finds a block with no header in it at all.
|
||||
const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the header is one row on a phone", () => {
|
||||
test("upright gets the shape a phone on its side already had", () => {
|
||||
// These four together are the whole change: don't wrap, drop the word beside the
|
||||
// arrow, clip the title, and let the buttons size themselves in what is left.
|
||||
const phone = PHONE();
|
||||
assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/,
|
||||
"a wrapping header is a two-row header");
|
||||
assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/,
|
||||
"the arrow has to be a flex item of the header, not a block inside a block");
|
||||
assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped");
|
||||
assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone");
|
||||
});
|
||||
|
||||
test("both narrow layouts clip the title rather than removing it", () => {
|
||||
// `display: none` would take the page's only <h1> off the accessibility tree, and
|
||||
// a page whose heading disappears below 700px is a page with no heading on the
|
||||
// device it is mostly read on.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, "#holdemHeader h1");
|
||||
assert.ok(rule, `${which} does not address the title`);
|
||||
assert.doesNotMatch(rule, /display:\s*none/,
|
||||
`${which} removes the page's heading instead of hiding it`);
|
||||
assert.match(rule, /clip-path:\s*inset\(50%\)/,
|
||||
`${which} should clip the heading the way .visuallyHidden does`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the actions row stops claiming a row of its own", () => {
|
||||
// `width: 100%` is what made it a second row: a flex child at full width cannot
|
||||
// share a line with anything, whatever the wrap setting says.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions") || "";
|
||||
assert.doesNotMatch(rule, /width:\s*100%/);
|
||||
assert.match(rule, /flex:\s*1/, "it should take the width the title gave up");
|
||||
});
|
||||
|
||||
test("and the buttons grow into that width without ever shrinking below their labels", () => {
|
||||
// `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳"
|
||||
// — 105px of text — into 99. These are `white-space: nowrap`, so what gives is
|
||||
// the text.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
|
||||
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
|
||||
assert.match(rule, /white-space:\s*nowrap/);
|
||||
assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens");
|
||||
});
|
||||
|
||||
test("the arrow keeps a thumb-sized box once it loses its word", () => {
|
||||
// The word used to carry the width, and the padding only had to make up the
|
||||
// height. With the word gone the arrow is the whole target, and the first
|
||||
// attempt at 32px was measured by the layout sweep at 31x43 on every touch
|
||||
// viewport it runs — 56 scenarios, one control.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, ".holdemBackLink") || "";
|
||||
assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`);
|
||||
// The negative margins that gave the hit area back to a two-row header would
|
||||
// now pull the arrow out of a row it is the first item of.
|
||||
assert.match(rule, /margin:\s*0/);
|
||||
});
|
||||
});
|
||||
|
||||
test("and it still says where it goes when the word is gone", () => {
|
||||
// An "←" on its own is announced as "left arrow" and names nothing. The label
|
||||
// contains the visible text on the sizes that still show it, which is what
|
||||
// WCAG 2.5.3 asks of a name that says more than the label does.
|
||||
const link = PAGE.match(/<a href="\/casino"[^>]*>/);
|
||||
assert.ok(link, "the back link is gone");
|
||||
const label = link[0].match(/aria-label="([^"]+)"/);
|
||||
assert.ok(label, "the back link has no accessible name for when the word is hidden");
|
||||
assert.match(label[1], /Casino/,
|
||||
"the visible word has to appear in the spoken name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand number found somewhere to live", () => {
|
||||
test("it is not a pill beside the page title any more", () => {
|
||||
assert.ok(!PAGE.includes("handCounterBadge"),
|
||||
"a counter read after the fact does not earn a line of a phone's header");
|
||||
});
|
||||
|
||||
test("it is in the row that already says what hand you are in", () => {
|
||||
// The street, the pot and the player count are the other three facts about the
|
||||
// current hand, and they are drawn together. This one costs nothing there: the
|
||||
// row was on the screen already.
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"),
|
||||
"utf8");
|
||||
// Sliced from the top of renderStatus to the first mention of the street, so
|
||||
// finding the hand chip inside that slice is the ordering claim as well as the
|
||||
// presence one: it is appended before the street or it is not in here.
|
||||
const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet"));
|
||||
assert.match(status, /actionHand/,
|
||||
"the status row does not say which hand this is, or says it after the street");
|
||||
});
|
||||
|
||||
test("a table that has never dealt says #1, not #0 and not #undefined", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
delete state.handNumber;
|
||||
assert.equal(ActionBar.handLabel(state), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12");
|
||||
assert.equal(ActionBar.handLabel(null), "#1");
|
||||
});
|
||||
|
||||
test("it is drawn quieter than the street beside it", () => {
|
||||
// The street is read every deal; this is read when somebody asks about a hand
|
||||
// that is already over. Same row, different job, and the colour says so.
|
||||
const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/);
|
||||
assert.ok(rule, ".actionHand has no rule");
|
||||
assert.match(rule[1], /color:\s*var\(--text-dim\)/);
|
||||
// A number that ticks over while the dealer is looking at it.
|
||||
assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+472
-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) ----
|
||||
|
||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||
@@ -454,6 +652,23 @@ const LayoutAudit = {
|
||||
};
|
||||
},
|
||||
|
||||
// Two things this check cannot do, both found by measuring against real pixels in
|
||||
// runContrastAudit.js and neither of them fixable from inside a DOM walk:
|
||||
//
|
||||
// 1. It reads `background-color`, so anything painted by a gradient is invisible to
|
||||
// it. The chair number on an empty seat sat on the felt's brown rim at 2.65:1 and
|
||||
// the pot's label on green at 2.66:1 — two plain AA failures this reported clean,
|
||||
// for years, because it never saw either background.
|
||||
// 2. Where an element has several candidate ancestors it takes the best contrast of
|
||||
// any of them ("the kindest reading available", below). That is the right instinct
|
||||
// for a fade, and it is what let (1) through: the flattering candidate was the
|
||||
// dark page behind the felt, which is not what the text is sitting on.
|
||||
//
|
||||
// Both are why the sibling audit screenshots the page with its text made transparent
|
||||
// and samples the pixels instead. This one is kept because AA is the number an
|
||||
// accessibility audit will ask for, and because it runs against a live DOM with no
|
||||
// screenshot to decode — but a clean result here is not a claim that the page is
|
||||
// readable. That claim belongs to the APCA sweep.
|
||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||
const offenders = [];
|
||||
const unmeasured = [];
|
||||
@@ -769,6 +984,211 @@ const LayoutAudit = {
|
||||
return { name: "hidden means hidden", offenders };
|
||||
},
|
||||
|
||||
// A mark on the felt that stands for nothing.
|
||||
//
|
||||
// The amount pill inside every seat puck has a background, a pill radius and side
|
||||
// padding, so a player who had not put chips in yet got an 8x11px green lozenge
|
||||
// where their bet would go — measured on seven of nine seats at an ordinary table.
|
||||
// One of those is a component holding its place; nine of them is a rash, and a
|
||||
// dealer reading the felt has to learn to ignore a colour that on three other seats
|
||||
// means "this player has money out".
|
||||
//
|
||||
// It cost room as well as attention. The ring is sized from the tallest puck on it,
|
||||
// so a row nobody had filled was setting how far apart the seats had to sit.
|
||||
//
|
||||
// The rule is about paint, not about space: an element may hold its place invisibly,
|
||||
// and several here do. What it may not do is draw a background or a border with
|
||||
// nothing inside it.
|
||||
//
|
||||
// One real exemption, and it is on the list because the check reported it rather
|
||||
// than because it was predicted: .betChipDisc is a 13x13px circle filled with the
|
||||
// denomination colour of the chip beside it, published per chip from render.js. It
|
||||
// has no text because the colour *is* the content. Anything carrying its own
|
||||
// picture in a background-image is exempt on the same grounds, without needing to be
|
||||
// named. Nothing else here needs an entry — three others were guessed at first and
|
||||
// matched no element on the page, which is its own warning about writing this list
|
||||
// from memory instead of from a run.
|
||||
emptyDecoration() {
|
||||
const DECORATIVE = new Set(["betChipDisc"]);
|
||||
const opaque = value => value && value !== "none" && !/rgba\([^)]*,\s*0\s*\)/.test(value)
|
||||
&& value !== "rgba(0, 0, 0, 0)" && value !== "transparent";
|
||||
|
||||
const offenders = [...document.querySelectorAll("#pokerTableWrap *")]
|
||||
.filter(el => {
|
||||
if (el.children.length || (el.textContent || "").trim()) return false;
|
||||
if ([...el.classList].some(c => DECORATIVE.has(c))) return false;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width < 2 || box.height < 2) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
|
||||
if (opaque(cs.backgroundImage)) return false;
|
||||
return opaque(cs.backgroundColor) ||
|
||||
["Top", "Right", "Bottom", "Left"].some(side =>
|
||||
parseFloat(cs[`border${side}Width`]) > 0 && opaque(cs[`border${side}Color`]));
|
||||
})
|
||||
.map(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return `${this.describe(el)} :: nothing in it, painted ` +
|
||||
`${Math.round(box.width)}x${Math.round(box.height)}px`;
|
||||
});
|
||||
|
||||
return { name: "nothing on the felt is painted for nothing", offenders };
|
||||
},
|
||||
|
||||
// One green, and it is the thing to do next.
|
||||
//
|
||||
// The accent is a promise: this is the step. It only works while it is spent on one
|
||||
// control at a time, and it was being spent on two. Measured over a whole hand,
|
||||
// seventeen steps, before this went in: two accent controls at thirteen of them —
|
||||
// the bar's Call and the header's Resolve Pot, the identical gradient at opposite
|
||||
// ends of a phone — and at the other four the header alone, while the bar held the
|
||||
// only button there was to press. At showdown the bar's own "Showdown — resolve
|
||||
// pot" was blue-grey and the corner shortcut was green.
|
||||
//
|
||||
// A modal is its own view, so while one is open only what is inside it is judged —
|
||||
// a wizard's confirm is the primary action of the wizard, and the bar underneath it
|
||||
// is not on screen in any sense that matters. That is the same scoping rule the
|
||||
// occlusion check uses, and it comes from the same helper.
|
||||
//
|
||||
// Zero is not a failure. Between hands with nobody seated there is genuinely no next
|
||||
// step, and inventing one to keep a colour company would be worse than the silence.
|
||||
//
|
||||
// Six broken versions were run at it and six were caught, but two of them only after
|
||||
// the injection itself was fixed, and both failures were the same mistake: painting
|
||||
// a control green is not the same as putting a green control in front of anybody.
|
||||
// The first attempt turned every sidebar button green with the panels shut, so the
|
||||
// rule landed on nothing that was rendered — `getComputedStyle` reported the accent
|
||||
// gradient and `offsetParent` was still null. The second opened the panels first,
|
||||
// which rendered the button and left it four hundred pixels below the fold, where
|
||||
// this check is meant to ignore it. Scrolled into view, the same green "Add" is
|
||||
// named in the same breath as the bar's Call, and taking the green off goes clean
|
||||
// again. So: read back the computed paint AND the box, on both counts, before
|
||||
// believing a clean result means the check is looking.
|
||||
ACCENT_FILL: [22, 120, 58],
|
||||
ACCENT_TOLERANCE: 24,
|
||||
|
||||
wearsTheAccent(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
const paint = `${cs.backgroundColor} ${cs.backgroundImage}`;
|
||||
return [...paint.matchAll(/rgba?\(([^)]+)\)/g)]
|
||||
.map(m => m[1].split(",").map(Number))
|
||||
.some(([r, g, b, a]) => (a === undefined || a > 0.5) &&
|
||||
Math.abs(r - this.ACCENT_FILL[0]) <= this.ACCENT_TOLERANCE &&
|
||||
Math.abs(g - this.ACCENT_FILL[1]) <= this.ACCENT_TOLERANCE &&
|
||||
Math.abs(b - this.ACCENT_FILL[2]) <= this.ACCENT_TOLERANCE);
|
||||
},
|
||||
|
||||
oneNextStep() {
|
||||
const scope = this.modalScope();
|
||||
const root = scope || document;
|
||||
const onScreen = el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === "hidden" || +cs.opacity === 0) return false;
|
||||
return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth;
|
||||
};
|
||||
|
||||
const green = [...root.querySelectorAll("button, a.holdemBtn")]
|
||||
.filter(el => el.offsetParent !== null && onScreen(el) && this.wearsTheAccent(el));
|
||||
|
||||
if (green.length <= 1) {
|
||||
return { name: "one green, and it is the next step", offenders: [] };
|
||||
}
|
||||
return {
|
||||
name: "one green, and it is the next step",
|
||||
offenders: [`${green.length} controls wear the accent at once` +
|
||||
`${scope ? " inside the open sheet" : ""}: ` +
|
||||
green.map(el => `"${(el.textContent || "").trim().slice(0, 24)}" in ` +
|
||||
`${el.closest("#holdemHeader") ? "the header"
|
||||
: el.closest("#actionBar") ? "the action bar"
|
||||
: el.closest("#holdemSidebar") ? "the sidebar" : "the page"}`).join(", ")],
|
||||
};
|
||||
},
|
||||
|
||||
// The status a shut sidebar panel wears, and the three ways it can go wrong.
|
||||
//
|
||||
// Each of the eight <details> in the sidebar now carries the one fact you would
|
||||
// have opened it for — "Balanced", "2 waiting", "5 colours · $1–$500". They are the
|
||||
// sidebar's whole navigation, and what makes them scannable is that they are eight
|
||||
// identical slabs: a glance that pushes one row taller than the rest costs more in
|
||||
// rhythm than the fact it added is worth. Two of the strings aren't ours to keep
|
||||
// short — a table note is whatever the dealer typed, and the amounts grow with the
|
||||
// stakes — so this is checked on the page rather than argued about in the markup.
|
||||
//
|
||||
// Four questions, and they fail in different directions. A row that grew is the
|
||||
// layout one. A glance sitting on its own label is the narrow-screen one, and it is
|
||||
// the one that will come back: it takes a long label and a long glance in the same
|
||||
// panel, so it depends on the state and the viewport together. And a glance
|
||||
// ellipsised down to "Sam…" is worth less than no glance at all — it has spent a
|
||||
// row's width to say that a note exists, which the panel's own presence already
|
||||
// said.
|
||||
GLANCE_MIN_READABLE_PX: 60,
|
||||
|
||||
glancesFitTheirRows() {
|
||||
// Rows that are actually drawn. The Keyboard panel is display: none under
|
||||
// `pointer: coarse` — a touch device has no keys to press — and a panel that
|
||||
// isn't rendered measures 0px, which the shortest-row rule below happily took
|
||||
// as its idea of a bare row. Every real row then read as grown: caught by the
|
||||
// sweep on its first clean run, 91 offenders, seven rows across each of the
|
||||
// thirteen viewports visited with a thumb.
|
||||
const rows = [...document.querySelectorAll(".sidebarPanel > summary")]
|
||||
.filter(row => row.getBoundingClientRect().height > 0);
|
||||
if (!rows.length) return { name: "panel glances fit their rows", offenders: [], skipped: "no sidebar here" };
|
||||
|
||||
// The shortest row in the sidebar is what a row with nothing on it measures,
|
||||
// whichever one that happens to be — taken rather than declared, so a change to
|
||||
// the header's padding doesn't quietly make this check pass by moving the bar.
|
||||
const heights = rows.map(row => row.getBoundingClientRect().height);
|
||||
const bare = Math.min(...heights);
|
||||
|
||||
const offenders = [];
|
||||
rows.forEach((row, index) => {
|
||||
const glance = row.querySelector(".panelGlance");
|
||||
const label = row.querySelector("h2");
|
||||
const rowBox = row.getBoundingClientRect();
|
||||
const name = (label && label.textContent.trim()) || `panel ${index + 1}`;
|
||||
|
||||
if (heights[index] > bare + 1) {
|
||||
offenders.push(`${name} :: row is ${Math.round(heights[index])}px against ` +
|
||||
`${Math.round(bare)}px for a row with nothing on it`);
|
||||
}
|
||||
if (!glance || glance.hidden || !glance.textContent.trim()) return;
|
||||
|
||||
const box = glance.getBoundingClientRect();
|
||||
if (label) {
|
||||
const labelBox = label.getBoundingClientRect();
|
||||
if (box.left < labelBox.right) {
|
||||
offenders.push(`${name} :: "${glance.textContent}" sits on the label, ` +
|
||||
`${Math.round(labelBox.right - box.left)}px into it`);
|
||||
}
|
||||
}
|
||||
// Both axes, and the vertical one is the half that matters. The row's own
|
||||
// height cannot be trusted to report this: the first version of this check
|
||||
// asked only whether a row had grown, and a glance floated back into flow
|
||||
// and allowed to wrap sailed straight through it — a float doesn't expand
|
||||
// the box it sits in, so two lines of note hung 9px clear of a row that
|
||||
// still measured exactly 44px. Asking where the glance actually
|
||||
// ended up is the question that has an answer.
|
||||
const out = [
|
||||
box.right > rowBox.right + 1 ? "off the right" : null,
|
||||
box.left < rowBox.left - 1 ? "off the left" : null,
|
||||
box.top < rowBox.top - 1 ? `${Math.round(rowBox.top - box.top)}px out of the top` : null,
|
||||
box.bottom > rowBox.bottom + 1 ? `${Math.round(box.bottom - rowBox.bottom)}px out of the bottom` : null,
|
||||
].filter(Boolean);
|
||||
if (out.length) {
|
||||
offenders.push(`${name} :: "${glance.textContent}" hangs ${out.join(" and ")} of its row`);
|
||||
}
|
||||
if (box.width < this.GLANCE_MIN_READABLE_PX &&
|
||||
glance.scrollWidth > glance.clientWidth + 1) {
|
||||
offenders.push(`${name} :: "${glance.textContent}" is cut down to ` +
|
||||
`${Math.round(box.width)}px, which says less than nothing`);
|
||||
}
|
||||
});
|
||||
|
||||
return { name: "panel glances fit their rows", offenders };
|
||||
},
|
||||
|
||||
// A seat's name, painted under the decoration on its own puck.
|
||||
//
|
||||
// The chip that marks the button sits in the puck's top-right corner on a layer
|
||||
@@ -778,18 +1198,30 @@ const LayoutAudit = {
|
||||
// concerned it had all the room it needed. Nothing else here would have caught it:
|
||||
// the occlusion check is about controls a thumb has to reach, and this is a label
|
||||
// a dealer has to read.
|
||||
// Measured against the lines of text as they were painted, not against the box they
|
||||
// live in. This used to take the box and subtract its padding, on the reasoning that
|
||||
// "padding is room the text has already been kept out of" — which is true, and is
|
||||
// not the only way to keep text out of somewhere. A right float does it too, and it
|
||||
// does it per line, which is the point of using one: the name on the button seat is
|
||||
// now indented past the chip on the line beside it and full width on the line below.
|
||||
// Box-minus-padding cannot see that, and called every seat in the sweep an offender
|
||||
// while the text sat exactly where it was supposed to.
|
||||
//
|
||||
// Range rects are the direct question — where did this text actually end up — so
|
||||
// padding, floats, centring, wrapping and a short label in a wide box all come out
|
||||
// right without any of them being modelled. Line boxes rather than one bounding box,
|
||||
// too: a two-line name that clears the chip on the line beside it is not covered
|
||||
// because its *other* line happens to sit under where the chip is.
|
||||
seatLabelsClear() {
|
||||
const offenders = [];
|
||||
document.querySelectorAll(".seat").forEach(seat => {
|
||||
const name = seat.querySelector(".seatPuckName");
|
||||
if (!name || !name.textContent.trim()) return;
|
||||
const box = name.getBoundingClientRect();
|
||||
if (!box.width || !box.height) return;
|
||||
// Padding is room the text has already been kept out of, so it is not
|
||||
// somewhere the text can be covered.
|
||||
const style = getComputedStyle(name);
|
||||
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
||||
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(name);
|
||||
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||||
if (!lines.length) return;
|
||||
|
||||
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||
const over = mark.getBoundingClientRect();
|
||||
@@ -797,10 +1229,15 @@ const LayoutAudit = {
|
||||
const marked = getComputedStyle(mark);
|
||||
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||
parseFloat(marked.opacity) < 0.05) return;
|
||||
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
|
||||
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||
if (covered > 1 && vertical > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
||||
|
||||
let worst = 0;
|
||||
lines.forEach(line => {
|
||||
const across = Math.min(line.right, over.right) - Math.max(line.left, over.left);
|
||||
const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top);
|
||||
if (across > 1 && down > 1) worst = Math.max(worst, across);
|
||||
});
|
||||
if (worst > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -862,14 +1299,28 @@ const LayoutAudit = {
|
||||
// Along the line rather than at its middle: a badge takes the start
|
||||
// of it and a neighbouring puck the end, and a single centre probe
|
||||
// walks past both.
|
||||
//
|
||||
// Every probe, not the first one that hits. How much of the line is
|
||||
// gone is the difference between a stack a dealer cannot read and a
|
||||
// hairline where a badge's rounded corner grazes the last digit, and
|
||||
// reporting both as "is under" gave no way to tell them apart — the
|
||||
// one residual this sweep has carried for three rounds turns out to
|
||||
// be one probe of seven.
|
||||
let hits = 0;
|
||||
let culprit = null;
|
||||
let probes = 0;
|
||||
for (let across = 0.08; across <= 0.92; across += 0.14) {
|
||||
const x = line.left + (line.right - line.left) * across;
|
||||
const y = (line.top + line.bottom) / 2;
|
||||
probes++;
|
||||
const over = document.elementFromPoint(x, y);
|
||||
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
||||
offenders.push(
|
||||
`seat ${index + 1} "${text}" is under ${this.describe(over)}`);
|
||||
return;
|
||||
hits++;
|
||||
if (!culprit) culprit = over;
|
||||
}
|
||||
if (culprit) {
|
||||
offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} `
|
||||
+ `under ${this.describe(culprit)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1049,6 +1500,10 @@ const LayoutAudit = {
|
||||
const checks = [
|
||||
this.seatsOnTheTable(options.seatSlack),
|
||||
this.dialogActionsReachable(),
|
||||
this.controlNames(),
|
||||
this.focusHasRoom(),
|
||||
this.focusablesReachable(),
|
||||
this.furnitureBudget(),
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
@@ -1060,6 +1515,9 @@ const LayoutAudit = {
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.hiddenButPainted(),
|
||||
this.emptyDecoration(),
|
||||
this.oneNextStep(),
|
||||
this.glancesFitTheirRows(),
|
||||
this.seatLabelsClear(),
|
||||
this.seatsKeepTheirText(),
|
||||
this.chipsKeepTheirAmounts(),
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// When the tool asks the phone to stay awake, and — the part that actually matters —
|
||||
// when it lets go again.
|
||||
//
|
||||
// A dealer's phone lies face-up on the felt all night. Left alone it dims and locks
|
||||
// between the flop and the turn, every hand, and a dozen rounds of making the next step
|
||||
// readable at a glance count for nothing behind a lock screen.
|
||||
//
|
||||
// The risk runs the other way too, and it is the one worth testing. A wake lock that is
|
||||
// taken and not dropped is a phone that burns its battery through a break, a re-buy, and
|
||||
// the twenty minutes everyone spends arguing about pizza — and the dealer has no way to
|
||||
// tell that is what is happening. So the rule is narrow: held only while betting is
|
||||
// actually open, and only while the dealer wants it.
|
||||
//
|
||||
// The browser side of this is a stand-in below rather than a real navigator, which lets
|
||||
// the tests ask the questions a real table asks: what happens when the hand ends inside
|
||||
// the request, what happens when the phone takes the lock back on its own, and what
|
||||
// happens when the browser simply says no.
|
||||
const test = require("node:test");
|
||||
const { describe, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { WakeLock, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A stand-in for navigator.wakeLock that records what was asked of it. `deny` makes
|
||||
// every request reject, the way a phone in battery saver does.
|
||||
const fakeWakeLock = ({ deny = false } = {}) => {
|
||||
const api = {
|
||||
requests: 0,
|
||||
released: 0,
|
||||
live: [],
|
||||
deny,
|
||||
wakeLock: {
|
||||
request: async () => {
|
||||
api.requests += 1;
|
||||
if (api.deny) throw new Error("denied");
|
||||
const listeners = [];
|
||||
const sentinel = {
|
||||
released: false,
|
||||
addEventListener: (name, fn) => { if (name === "release") listeners.push(fn); },
|
||||
release: async () => {
|
||||
if (sentinel.released) return;
|
||||
sentinel.released = true;
|
||||
api.released += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
// What the browser does on its own when the tab stops being visible.
|
||||
dropFromOutside: () => {
|
||||
sentinel.released = true;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
};
|
||||
api.live.push(sentinel);
|
||||
return sentinel;
|
||||
},
|
||||
},
|
||||
};
|
||||
return api;
|
||||
};
|
||||
|
||||
// Settles the promises `sync()` deliberately doesn't return — it is called from
|
||||
// renderAll, which is synchronous, so the acquiring happens off to one side.
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// Node has had its own `globalThis.navigator` since v21, defined as a getter — so a
|
||||
// plain assignment to it does nothing at all and throws nothing either. Every test in
|
||||
// here that thought it had installed a fake would have been quietly measuring Node's
|
||||
// real navigator, which has no wakeLock, and passing for the wrong reason.
|
||||
const setNavigator = value => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value, configurable: true, writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
let realNavigator;
|
||||
|
||||
beforeEach(() => {
|
||||
realNavigator = globalThis.navigator;
|
||||
WakeLock.sentinel = null;
|
||||
WakeLock.wanted = false;
|
||||
WakeLock.pending = false;
|
||||
WakeLock.told = true; // The toast is DOM; it is checked in the browser.
|
||||
WakeLock.hooks = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(realNavigator);
|
||||
});
|
||||
|
||||
const useFake = api => {
|
||||
setNavigator(api);
|
||||
return api;
|
||||
};
|
||||
|
||||
// A table mid-hand, which is the only state that wants the screen.
|
||||
const dealtIn = () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
state.bettingOpen = true;
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the rule about when a table needs the screen", () => {
|
||||
test("a hand in progress does", () => {
|
||||
assert.equal(WakeLock.shouldHold(dealtIn()), true);
|
||||
});
|
||||
|
||||
test("a table between hands does not", () => {
|
||||
const state = dealtIn();
|
||||
state.bettingOpen = false;
|
||||
assert.equal(WakeLock.shouldHold(state), false);
|
||||
});
|
||||
|
||||
test("a dealer who turned it off does not, mid-hand or otherwise", () => {
|
||||
const state = dealtIn();
|
||||
state.keepScreenAwake = false;
|
||||
assert.equal(WakeLock.shouldHold(state), false);
|
||||
});
|
||||
|
||||
test("no table at all does not", () => {
|
||||
assert.equal(WakeLock.shouldHold(null), false);
|
||||
assert.equal(WakeLock.shouldHold(undefined), false);
|
||||
});
|
||||
|
||||
test("a table that has never heard of the setting still does", () => {
|
||||
// Every saved table from before this existed. The migration fills it in, but the
|
||||
// rule itself has to read a missing value as yes or the first hand after an
|
||||
// update runs without it.
|
||||
const state = dealtIn();
|
||||
delete state.keepScreenAwake;
|
||||
assert.equal(WakeLock.shouldHold(state), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking the lock and giving it back", () => {
|
||||
test("a hand starting takes one", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
WakeLock.sync(dealtIn());
|
||||
await settle();
|
||||
assert.equal(api.requests, 1);
|
||||
assert.ok(WakeLock.sentinel);
|
||||
});
|
||||
|
||||
test("the hand ending gives it back", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
state.bettingOpen = false;
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.released, 1);
|
||||
assert.equal(WakeLock.sentinel, null);
|
||||
});
|
||||
|
||||
test("turning the setting off mid-hand gives it back", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
state.keepScreenAwake = false;
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.released, 1);
|
||||
});
|
||||
|
||||
test("a whole street of renders takes exactly one", async () => {
|
||||
// sync() runs off renderAll, which fires many times a second while a street is
|
||||
// being bet. Every one of those must be a no-op, or the tool leaks a sentinel
|
||||
// per render and the screen stays lit long after the hand is over.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
}
|
||||
assert.equal(api.requests, 1);
|
||||
assert.equal(api.released, 0);
|
||||
});
|
||||
|
||||
test("two syncs in the same tick still take exactly one", async () => {
|
||||
// The `pending` guard. Without it both calls get past the "already holding one?"
|
||||
// check, and the second sentinel is never stored — so nothing can release it and
|
||||
// the screen stays awake for the rest of the night.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(api.requests, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the awkward moments", () => {
|
||||
test("a hand that ends while the request is in flight leaves nothing held", async () => {
|
||||
// A fold can end the hand between asking for the lock and being given it. The
|
||||
// sentinel that arrives afterwards belongs to a hand that no longer exists.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
|
||||
state.bettingOpen = false;
|
||||
WakeLock.sync(state);
|
||||
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
assert.equal(WakeLock.sentinel, null, "nothing should still be held");
|
||||
assert.equal(api.released, 1, "the one that arrived late should have been let go");
|
||||
});
|
||||
|
||||
test("the browser taking it back leaves us able to take it again", async () => {
|
||||
// What happens every time the dealer switches apps to check a rule. The browser
|
||||
// releases the lock itself and never gives it back; if we go on believing we
|
||||
// hold one, the screen sleeps for the rest of the night.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
api.live[0].dropFromOutside();
|
||||
assert.equal(WakeLock.sentinel, null, "the release listener should have cleared it");
|
||||
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
assert.equal(api.requests, 2, "coming back to the tool should take a fresh one");
|
||||
});
|
||||
|
||||
test("a browser that says no is not an error", async () => {
|
||||
const api = useFake(fakeWakeLock({ deny: true }));
|
||||
const state = dealtIn();
|
||||
await assert.doesNotReject(async () => {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
});
|
||||
assert.equal(api.requests, 1);
|
||||
assert.equal(WakeLock.sentinel, null);
|
||||
});
|
||||
|
||||
test("a browser without the API is left alone entirely", async () => {
|
||||
useFake({});
|
||||
assert.equal(WakeLock.supported(), false);
|
||||
const state = dealtIn();
|
||||
await assert.doesNotReject(async () => {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
});
|
||||
assert.equal(WakeLock.wanted, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a saved table remembers about the screen", () => {
|
||||
test("a new table keeps the screen on", () => {
|
||||
assert.equal(HoldemState.createDefault().keepScreenAwake, true);
|
||||
});
|
||||
|
||||
test("a table saved before the setting existed keeps the screen on", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
delete saved.keepScreenAwake;
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
|
||||
});
|
||||
|
||||
test("a dealer who turned it off still has it off after a reload", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
saved.keepScreenAwake = false;
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, false);
|
||||
});
|
||||
|
||||
test("junk in that field is read as on rather than trusted", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
saved.keepScreenAwake = "yes please";
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user