Merge pull request 'Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal' (#120) from polish/holdem-game-night-ready into master
Build and Deploy / build-deploy (push) Successful in 1m14s

Reviewed-on: #120
This commit was merged in pull request #120.
This commit is contained in:
2026-09-09 14:23:50 -06:00
27 changed files with 3993 additions and 111 deletions
+99 -19
View File
@@ -16,18 +16,38 @@
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>
@@ -87,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&rsquo; 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 &mdash; 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 &mdash; and the tool says so the first time it takes hold.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
@@ -289,6 +320,14 @@
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>
@@ -315,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">
@@ -331,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>
@@ -361,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>
@@ -699,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>
+33
View File
@@ -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
+499 -54
View File
@@ -57,11 +57,31 @@
--line: #3a3a3a;
--line-strong: #4a4a4a;
/* Text. */
/* Text.
These two were picked to clear WCAG AA — several rules in this file still say so
in their own comments, one of them "measured 4.5:1 at #a5a5a5 exactly, so it sits
above that". That was the right instinct with the wrong instrument. WCAG 2's
ratio is a luminance gap, and a luminance gap overstates readability once one of
the colours is near black, which every reading on this page is: the page is
#141414 and the panels are darker than the rows. Tuning to 4.5:1 against a
near-black ground lands on text that passes the audit and still reads as a smudge
across a table in a dim room.
Re-measured with APCA, which scores the pair for the size and weight it is
actually drawn at: #9a9a9a on a panel came out at Lc 46 and #8a8a8a at Lc 38,
against a floor of Lc 60 for a label and Lc 75 for anything read to make a
decision. Fifty-four separate pieces of text were below their level while passing
AA. These are the values that clear it — muted at Lc ~71, dim at Lc ~59.
They are lighter than they were, and the hierarchy did not come out of it: --text
is still Lc 95, so the three tiers are 95 / 71 / 59, which is a wider spread than
the 95 / 46 / 38 it replaces. Fading is not the only way to say "secondary", and
on a dark ground it is the most expensive one — size and weight say it without
spending contrast, which is what the seat puck does now. */
--text-bright: #f5f5f5;
--text: #eee;
--text-muted: #9a9a9a;
--text-dim: #8a8a8a;
--text-muted: #c8c8c8;
--text-dim: #b4b4b4;
/* The one accent. Desaturated against the dark ground — a fully saturated gold
vibrates on a near-black surface and stops reading as a highlight. */
@@ -249,19 +269,55 @@ button,
(`translate(-50%, -50%)` plus its own left/top). A press state setting `transform`
would drop that translate and throw every pressed seat about 38px off its spot. */
/* Nobody should get a stuck press state from a tap that turned into a scroll. */
/* Nothing on this page moves under the pointer for anyone who asked it not to.
`:hover` and `:active` together, because they are the same 1px of travel and the
setting does not distinguish them.
This list used to hold nine `:active` selectors and its membership looked arbitrary,
because it was: it was 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.
Solving one problem with another's tool is how it came to cover `.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 — and every
`:hover` in the file.
Read as its own rule rather than as a side effect of that one, the membership is not
a judgement call at all: every `:hover` and `:active` transform in the stylesheet
belongs here. reducedMotion.test.js scans for them and fails on any that is missing,
so a new one cannot be added without this being updated. */
@media (prefers-reduced-motion: reduce) {
.holdemBtn:hover,
.holdemBtn:active:not(:disabled),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.cardSlot:hover,
.cardSlot:active,
.pickerCard:hover:not(:disabled),
.pickerCard:active:not(:disabled),
.dealerChip:active,
.raiseQuickBtn:active:not(:disabled),
.actionUndo:active:not(:disabled) {
.actionBtn:active,
.actionUndo:active:not(:disabled),
.toastAction:active,
.toastClose:active,
.potWizardChoice:active {
transform: none;
}
/* The seat sheet's disclosure arrows keep their two positions and lose the swing
between them. Not in the list above — nothing here is under a pointer, and the
end state is meaningful: an arrow that pointed the same way open and shut would
have stopped saying anything.
The scrim loses its fade with it, and the delay goes with the fade: with nothing
to cover, a quarter second of visibility held open is a scrim still painted over
a sheet that has already gone. `none` flips it at once in both directions, which
is what the sheet's own reduced-motion rule concluded for the same reason. */
.seatSubPanel > summary::before,
#seatSheetScrim {
transition: none;
}
}
/* ---------- Border-box, everywhere ----------
@@ -366,16 +422,10 @@ button,
}
}
.handCounterBadge {
display: inline-block;
margin-top: var(--sp-2);
padding: var(--sp-1) var(--sp-3);
border-radius: var(--r-pill);
background: rgba(216, 180, 106, 0.12);
border: 1px solid rgba(216, 180, 106, 0.4);
color: var(--gold);
font-size: var(--fs-sm);
letter-spacing: 0.04em;
/* The word beside the arrow, which a phone drops. Below 700px the link is the arrow
and a 44px box around it; the name a screen reader gets is on the <a> either way. */
.backLinkWord {
white-space: nowrap;
}
#holdemHeaderActions {
@@ -689,9 +739,23 @@ button,
}
}
/* The disc is deeper than it was. Its bright stop left the label sitting on a mid green
that measured Lc 38 and — the reading that settles it — 3.79:1, which is a plain AA
failure the audit had never reported because it reads background-color and this is a
gradient over the felt.
Deepening the disc rather than bleaching the label is what keeps the accent: gold on
a dark green is the pairing this whole page is built on, and gold on a mid green is
the one place it wasn't. The pot is also the number read from furthest away, so a
disc that separates harder from the felt is the right answer twice over.
Both stops, not just the far one. Deepening only the outer stop moved the label by
two points of Lc and no more, because the highlight is at 32%/28% and that is exactly
where the label sits — the reading was being taken on the brightest part of the disc.
The highlight is still there, it is just a lift within a dark disc now rather than a
wash of white over a mid green. */
#potBadge {
position: relative;
background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55));
background: radial-gradient(circle at 32% 28%, rgba(0, 0, 0, 0.34), rgba(0, 0, 0, 0.78));
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--r-pill);
padding: var(--sp-3) var(--sp-5);
@@ -702,11 +766,16 @@ button,
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6), 0 4px 14px rgba(0, 0, 0, 0.45);
}
/* --gold is tuned to sit on the near-black of the panels and the bar. The pot badge is
the one place it lands on green instead, and green is the worst possible ground for
it: measured Lc 38 there against Lc 70-odd everywhere else the same token is used.
The brighter step of the same accent clears it without the badge losing the accent —
white would read as a second amount next to the one underneath it. */
#potBadgeLabel {
font-size: var(--fs-xs);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
color: var(--gold-bright);
}
#potBadgeAmount {
@@ -913,14 +982,29 @@ button,
text-decoration-color: rgba(224, 123, 123, 0.75);
}
/* An empty seat used to be a 2%-white wash at 0.7 opacity, which is another way of
saying it had no background of its own and whatever the felt was doing behind it
decided how readable its label was. The felt is a gradient with a brown rim, so that
varied by chair: the same #aeaeae chair number measured Lc 28 on the rim, Lc 30 on
the dark green and Lc 33 further in. Text whose contrast depends on where the seat
happens to sit cannot be reasoned about, and the fade was making every part of it
worse at once.
A ground of its own fixes both. The dashed border and the EMPTY label are what say
"open" — they always were — so the fade was not carrying the meaning, only costing
contrast. It stays louder than seatInactive, which is what the old comment here was
protecting and is still true.
0.62 and not the 0.82 this was first written at. Contrast turns out to be almost flat
across that range — Lc 69.6 against the rim at 0.62 versus 71.7 at 0.82, because the
label is light and every ground in that range is dark — so the heavier wash bought
about two points of Lc and cost four dark rectangles sitting on the felt like holes.
Taking the lighter one is free. */
.seat.seatEmpty {
background: rgba(255, 255, 255, 0.02);
background: rgba(18, 18, 18, 0.62);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.13);
border-color: rgba(255, 255, 255, 0.22);
box-shadow: none;
/* Deliberately louder than seatInactive: an open seat is information a
dealer wants, not something to squint at. */
opacity: 0.7;
}
.seatEmptyLabel {
@@ -935,16 +1019,17 @@ button,
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
}
/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: var(--text-dim)
came out at 3.39:1 once the fade was composited, for the label a dealer reads to
answer "which seat is seat 6?". Lifted until it passes with a little to spare —
measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
/* The chair number answers "which seat is seat 6?", and it is the smallest text on the
felt. It was set to a hand-picked #aeaeae chosen for being a hair over 4.5:1 — see
the token block for why that bar was the wrong one, and the rule above for why the
fade behind it made the reading meaningless anyway. On the token now, so it moves
with the rest of the page instead of being pinned to a number from one measurement. */
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
font-size: var(--fs-2xs);
color: #aeaeae;
color: var(--text-muted);
}
/* Two lines, not an ellipsis.
@@ -1296,9 +1381,13 @@ button,
color: #eaf3ff;
}
/* Lightened a step. Dark-on-gold was the right call and stays; the gold underneath it
was just too deep to carry 9px bold at its bottom stop, which measured Lc 46 while
the small blind's blue was already at Lc 84. Raising the gold is what closes the gap,
and it costs nothing — the badge reads as the same gold chip, only brighter. */
.bbBadge {
left: 2px;
background: linear-gradient(180deg, #e6ad4d, #c98a2a);
background: linear-gradient(180deg, #f0c069, #e2a845);
color: #241a05;
}
@@ -1358,6 +1447,17 @@ button,
font-size: var(--fs-2xs);
}
/* Not wrapping is only half an answer: a line that cannot wrap and still does not fit
paints over the seat next door instead. So on the tightest pucks the mark gives up
most of its letter-spacing, which is 3.6px of the 36 it wanted.
The spacing is there to stop "ALL IN" reading as money at a glance, and it keeps the
two signals that actually do that job — the warm colour and the bold weight. What it
gives up is the third one, on the only pucks too narrow to afford it. */
.seatsTight .seatPuckStack.seatPuckAllIn {
letter-spacing: 0.01em;
}
/* The other end of the same idea. A puck past the ordinary 96px ceiling has room the
type was never given: the felt grows with the window and the text did not, so a
dealer on a 2560px monitor read their stacks at the same 10px as one on a 360px
@@ -1414,10 +1514,11 @@ button,
white-space: nowrap;
}
/* Grip and Done button are the sheet's furniture — no reason for either on a
desktop sidebar, where the panel is just a panel. */
/* Grip, Done button and scrim are the sheet's furniture — no reason for any of them
on a desktop sidebar, where the panel is just a panel. */
.seatSheetGrip,
.seatSheetClose {
.seatSheetClose,
#seatSheetScrim {
display: none;
}
@@ -1436,6 +1537,66 @@ button,
gap: var(--sp-2);
}
/* A label and a field, and the field takes what is left.
`.fieldRow input` caps at 140px, which is the right width in a 320px sidebar of
settings and the wrong one in a sheet that has the whole phone: "Name" sat against
the left edge with 203px of nothing between it and a field a third of the row wide.
The row's height does not change — this is the same 44px control, given the width
the sheet already had. */
.seatEditorForm .fieldRow input:not([type="checkbox"]) {
flex: 1;
min-width: 0;
max-width: none;
}
/* The three folded sections. `.subPanel` already draws the rule above and the muted
summary; what these add is the room to hit one with a thumb and the fact on the
right that says whether opening it is worth the tap. */
.seatSubPanel > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-1) 0;
list-style: none;
}
/* A flex <summary> loses its disclosure triangle in Safari and Chrome alike, so the
marker is drawn here instead — and it is the one piece of this sheet that says
"there is more underneath" before anything is tapped. */
.seatSubPanel > summary::before {
content: "▸";
flex: none;
font-size: var(--fs-xs);
color: var(--text-dim);
transition: transform 0.15s ease;
}
.seatSubPanel[open] > summary::before {
transform: rotate(90deg);
}
.seatSubPanel > summary::-webkit-details-marker {
display: none;
}
.seatSubPanelName {
flex: 1;
min-width: 0;
}
/* The answer, on the shut row. Sized and coloured like the sidebar's panel glances,
because it is the same idea one level down: the one fact worth opening for. */
.seatGlance {
flex: none;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.seatCardToggles {
display: flex;
gap: var(--sp-2);
@@ -2142,15 +2303,21 @@ button,
line-height: 1;
}
/* The rule above it is the disclosure's own now, so this no longer draws one of its
own — two lines a few pixels apart was the look of a section that didn't know where
it started. */
.seatContribution {
border-top: 1px dashed var(--line);
padding-top: var(--sp-2);
margin-bottom: var(--sp-1);
}
/* A grid rather than a wrapping flex row. Five chip colours in a flex row came out
four-and-one, with the fifth stranded on a line of its own and the four above it
ending wherever their widths happened to land — a ragged block for a set of things
that are all the same shape. Columns are as wide as they need to be for a value and
a count, and every cell is the same. */
.chipCounts {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
gap: var(--sp-1);
margin-bottom: var(--sp-1);
}
@@ -2159,17 +2326,32 @@ button,
display: flex;
align-items: center;
gap: var(--sp-1);
min-width: 0;
}
.chipSwatch {
width: 12px;
height: 12px;
flex: none;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
display: inline-block;
}
/* What the colour is worth, in the row. Fixed-width and tabular so five of these
stack into a column the eye can run down, rather than five labels each starting
somewhere else. */
.chipCountValue {
flex: none;
min-width: 2.75rem;
color: var(--text-muted);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.chipCountInput {
flex: 1;
min-width: 0;
width: 34px;
background: var(--surface-1);
border: 1px solid var(--line);
@@ -2898,7 +3080,7 @@ body.hasActionBar #holdemApp {
border-radius: var(--r-pill);
border: 1px solid #414141;
background: #262626;
color: #bdbdbd;
color: var(--text-muted);
font-family: inherit;
font-size: var(--fs-sm);
cursor: pointer;
@@ -2915,6 +3097,16 @@ body.hasActionBar #holdemApp {
}
}
/* Which hand this is, at the head of the row that says what hand you are in. Quieter
than the street beside it — the street is read every deal and this is read when
somebody asks — and tabular, because it is a number that ticks over while the dealer
is looking at it. */
.actionHand {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
font-size: var(--fs-xs);
}
.actionStreet {
color: var(--gold);
font-weight: 600;
@@ -2929,7 +3121,7 @@ body.hasActionBar #holdemApp {
}
.actionPlayers {
color: #9c9c9c;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
@@ -2941,7 +3133,7 @@ body.hasActionBar #holdemApp {
.actionWho {
margin-left: auto;
color: #9c9c9c;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -3253,7 +3445,7 @@ body.hasActionBar #holdemApp {
padding: 0 var(--sp-1);
border-radius: var(--r-sm);
border: 1px solid currentColor;
opacity: 0.75;
opacity: 0.9;
font-family: inherit;
font-size: var(--fs-2xs);
line-height: 1.4;
@@ -3264,7 +3456,12 @@ body.hasActionBar #holdemApp {
/* A key hint is small — 9.6px — so AA asks 4.5:1 of it, and at the opacity these used
to carry they measured 2.97 to 3.33 against their buttons. That's the same fault as
the folded seats above: a fade chosen for looks pushing text a dealer is meant to
read under the threshold. They're still quieter than the label they sit beside. */
read under the threshold. They're still quieter than the label they sit beside.
Raised again from 0.75 to 0.9 for the same reason the text tokens moved: clearing
4.5:1 was never the same thing as being readable on this ground, and a fade is the
most expensive way to say "quieter" when the ground is already near black. The
keycap's border and its size still say it, and both are free. */
.actionKey {
position: absolute;
top: 5px;
@@ -3272,7 +3469,7 @@ body.hasActionBar #holdemApp {
padding: 0 var(--sp-1);
border-radius: var(--r-sm);
border: 1px solid currentColor;
opacity: 0.75;
opacity: 0.9;
font-family: inherit;
font-size: var(--fs-2xs);
line-height: 1.4;
@@ -3312,10 +3509,29 @@ body.hasActionBar #holdemApp {
color: var(--gold-bright);
}
/* Deal Flop, Deal Turn, Deal River, "Showdown — resolve pot", "Award pot to Dee".
Every one of them is the only thing the bar is offering at the moment it appears, and
every one of them was the quietest control on the screen.
Measured over a whole hand, seventeen steps: the accent green was on two controls at
thirteen of them — the bar's Call and the header's Resolve Pot, painted the identical
gradient at opposite ends of a phone — and at the other four it was on the header
alone while the bar held the only button there was to press. At showdown that is as
wrong as it gets: "Showdown — resolve pot" is the most consequential tap in the hand
and it was blue-grey, while a shortcut in the corner that had looked exactly the same
for the previous five minutes was green.
So the accent goes where the next step is. The same green as Call, deliberately,
because the rule worth learning is "green is the thing to do next" and not "green is
a player's action" — and the two can never be confused, because they never appear
together: renderStreetDone and renderHandOver each build their own row and neither
ever renders alongside the fold/call/raise triad. Checked over a full hand, and
checked again by the audit on every viewport. The fold red and the raise gold are
untouched, and the labels and the shortcut glyph still tell them apart. */
.actionAdvance {
background: linear-gradient(180deg, #2b3f57, #1b2a3a);
border-color: #3d5f83;
color: #dceaf8;
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
}
/* ---------- Raise pad ---------- */
@@ -3420,11 +3636,38 @@ body.hasActionBar #holdemApp {
}
}
/* One line, always — the same rule every design system gives a badge, and the one
thing on this puck that was never told it.
A stack figure has no spaces in it, so it could never wrap; it overflows instead, and
the puckAmountLong/Huge sizes below exist to stop it. "ALL IN" has a space, and that
space cost the two smallest phones their seat layout.
Measured at 360x640 with twelve players all-in: "ALL IN" wanted 36px and had 34. It
broke onto a second line, an 11px line became 22, and the puck went from 55px tall to
66. Height is exactly what is scarce at the ends of the ring — the seats there are
separated vertically, so a puck 11px taller is a puck 11px further into its
neighbour. The layout search then walked its candidate widths, found none that
cleared, and settled for the narrowest, which is also the one that wraps hardest.
Two pixels, and every seat on the felt paid for them. */
/* The stack is not secondary text, and it had been styled as some for as long as this
file has existed: muted grey 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 single action, because it is what decides whether a player
can cover the bet. It measured Lc 44 on the puck, the dimmest thing on the felt that
anyone has to read.
Set in --text now. The name is still the louder of the two — it is bigger and it is
600 — so the hierarchy is intact; it is just carried by size and weight instead of by
fading the number underneath it. */
.seatPuckStack {
font-size: var(--fs-2xs);
color: var(--text-muted);
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: var(--sp-0);
white-space: nowrap;
}
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
@@ -3533,6 +3776,33 @@ body.hasActionBar #toastHost {
transform: translateY(0);
}
/* Reduced motion keeps the fade and loses the travel.
This file already honoured the setting in three places, and all three were the wrong
size of thing: a 2px press-down on a button, a checkbox's tick, and the acting seat's
pulse. What it never covered was the motion that actually moves something across the
screen — and for anyone who gets motion sick, a 12px slide is the part that matters,
not a button dipping under a thumb.
Deleting the animation outright is the wrong answer too. A toast that simply appears,
already in place, reads as a rendering glitch rather than as news, and the same
guidance that says to cut the movement says to keep something: fade it instead, so
the interface still feels like it responded. */
@media (prefers-reduced-motion: reduce) {
#toastHost {
transition: none;
}
.toast {
transform: none;
transition: opacity 0.18s ease;
}
.toast.toastVisible {
transform: none;
}
}
.toastText {
flex: 1;
min-width: 0;
@@ -4138,26 +4408,92 @@ body.hasActionBar #toastHost {
padding: var(--sp-4) var(--sp-3) calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
}
/* ---- One row, the way a phone on its side already had it ----
The header stood 120px tall on a 390px phone and only 46 of that was a control.
The other 74 were a 22px title with a gold rule under it, a back link and a gold
"Hand #1" pill — read once, at the top of a screen whose subject is the green
table underneath. Sixteen percent of the window, before the felt started.
A phone held sideways has solved this since the landscape block went in: one row,
title clipped, arrow and buttons. Upright never got the same answer, and upright
is how this tool is held. It is 44px now, and the felt is 76px taller — 496 to
572, about fifteen percent more table on the same screen.
Nothing left the page. The title is still the document's <h1>, clipped rather
than removed, so it is still the heading a screen reader lands on and still what
the browser tab and the installed app are called. The hand number moved into the
action bar's status row, where the street, the pot and the player count already
say what hand you are in. And the arrow keeps its 44px box; only the word "Casino"
is gone, which is what every phone does with a back link. */
#holdemHeader {
flex-wrap: nowrap;
align-items: center;
margin-bottom: var(--sp-4);
gap: var(--sp-2);
}
/* So the arrow is a flex item of the header itself rather than a block inside a
block — there is nothing else left in this group once the title is clipped. */
#holdemHeaderLeft {
display: contents;
}
/* Clipped, not removed: `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. */
#holdemHeader h1 {
font-size: var(--fs-xl);
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.backLinkWord {
display: none;
}
/* 44 square, not 32. 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
layout sweep caught it at 31x43 on every touch viewport there is. It costs
nothing — the arrow and the three buttons come to 323px of the 336 a 360px phone
has, which is the narrowest screen in the sweep. */
.holdemBackLink {
flex: none;
justify-content: center;
min-width: 44px;
/* 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. */
margin: 0;
font-size: var(--fs-md);
}
/* Takes the row rather than shrink-wrapping to the right of it. Sized to their
labels the three buttons left 78px of nothing where the title used to be, and
that gap buys nothing — spent on the buttons instead it is about 26px of extra
target each, on the controls a dealer presses once a hand with a thumb. */
#holdemHeaderActions {
width: 100%;
gap: var(--sp-2);
flex: 1 1 auto;
flex-wrap: nowrap;
min-width: 0;
gap: var(--sp-1);
}
/* Grow into that spare width, never shrink below the label. `flex: 1 1 0` would
divide the row into equal thirds and squeeze "Next Hand ⟳" — 105px of text — into
99, and these are `white-space: nowrap`, so what gives is the text. An `auto`
basis makes the label the floor and the spare width the bonus. */
#holdemHeaderActions .holdemBtn {
flex: 1 1 auto;
justify-content: center;
min-height: 46px;
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-base);
padding: var(--sp-2) var(--sp-2);
font-size: var(--fs-sm);
white-space: nowrap;
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
@@ -4198,6 +4534,50 @@ body.hasActionBar #toastHost {
/* ---- Seat editor as a bottom sheet ---- */
/* What the sheet is in front of.
Lighter than the 0.72 the other six overlays use, on purpose: those replace the
table, and this one is a form *about* something on it. The chair being edited
should still read through the dim, and at 0.55 it does — the felt stays a felt
and the header behind it stops looking like somewhere to put a thumb. */
/* Put away with visibility, not with opacity — the same lesson the sheet above it
already learned about transforms, and it was caught the same way. A fixed,
full-window box at `opacity: 0` is invisible and still entirely there: the layout
sweep read it as furniture standing 640px tall at the bottom of a 640px phone, on
every screen and in every state, and asked scroll-padding to reserve the whole
window for it. Hidden is hidden — off the accessibility tree, out of hit testing,
and out of that measurement.
The delay is what keeps the fade-out on screen for its own quarter second:
visibility flips at the end of the 0.25s going out, and immediately coming in. */
#seatSheetScrim {
display: block;
position: fixed;
inset: 0;
z-index: 59;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0s linear 0.25s;
}
body.seatSheetOpen #seatSheetScrim {
opacity: 1;
visibility: visible;
transition: opacity 0.25s ease, visibility 0s;
}
/* And the toast steps over the sheet, the way it already steps over the action
bar. Both are fixed to the bottom edge, and the sheet is the taller of the two
while it is open — a toast left at the bar's height lands in the middle of the
seat form, with a live Undo and a live × sitting on top of the fields. Written
after the `body.hasActionBar` rule it overrides: equal specificity, later wins.
The height is measured and published by Render.watchSeatSheetHeight, so it
follows the sheet when a section is unfolded rather than assuming a size. */
body.seatSheetOpen #toastHost {
bottom: calc(var(--seatSheetHeight, 0px) + 0.75rem + env(safe-area-inset-bottom, 0px));
}
#seatEditorPanel {
position: fixed;
left: 0;
@@ -4282,6 +4662,20 @@ body.hasActionBar #toastHost {
text-align: center;
}
/* A section's name is a control, and a thumb has to be able to land on it without
aiming. Same 44px floor as every field below it, and the same 15px the toggles
and the total are set in — a summary at 12px reads as a caption for the row
above rather than as the thing you press. */
.seatEditorForm .seatSubPanel > summary {
min-height: 44px;
font-size: var(--fs-md);
color: var(--text);
}
.seatEditorForm .seatGlance {
font-size: var(--fs-base);
}
.seatEditorForm .chipCounts {
gap: var(--sp-2);
}
@@ -4431,6 +4825,30 @@ body.hasActionBar #toastHost {
}
}
/* The seat sheet, for anyone who asked not to be moved.
This is the largest movement the tool makes: on a phone the seat editor is a bottom
sheet that travels 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.
Placed after the 700px block rather than inside it because the rule it overrides is an
ID selector: at equal specificity the later one wins, and a copy sitting earlier in
the file would have looked right and done nothing.
The visibility delay has to go with the transform, and that is the part worth being
careful about. It exists so the slide-out stays on screen for its own 0.25s; with the
slide gone, the same delay leaves a sheet that has already jumped away still painted
for a quarter of a second. So it flips at once here, in both directions. */
@media (max-width: 700px) and (prefers-reduced-motion: reduce) {
#seatEditorPanel {
transition: visibility 0s linear 0s;
}
body.seatSheetOpen #seatEditorPanel {
transition: visibility 0s;
}
}
/* ---------- A phone on its side ----------
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
@@ -4494,8 +4912,12 @@ body.hasActionBar #toastHost {
they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
the page title: the one thing on this screen a dealer already knows. It is hidden
from the eye rather than removed, so the page keeps its heading for a screen
reader and for the browser's own outline. The hand number stays — nothing else on
the screen says which hand this is. */
reader and for the browser's own outline.
The upright block above now does all of this too, and the hand pill it used to
make room for has gone to the action bar's status row for both of them. What is
left here is what upright does not need: this query reaches up to 900px wide,
where the 700px block does not, so a phone held sideways still needs to be told. */
#holdemHeader {
margin-bottom: var(--sp-2);
gap: var(--sp-1);
@@ -4503,6 +4925,10 @@ body.hasActionBar #toastHost {
align-items: center;
}
#holdemHeaderLeft {
display: contents;
}
#holdemHeader h1 {
position: absolute;
width: 1px;
@@ -4514,17 +4940,28 @@ body.hasActionBar #toastHost {
white-space: nowrap;
}
.handCounterBadge {
margin-top: 0;
.backLinkWord {
display: none;
}
.holdemBackLink {
flex: none;
justify-content: center;
min-width: 44px;
margin: 0;
font-size: var(--fs-md);
}
/* Tightened across, never down: the buttons keep the 44px a thumb needs. */
#holdemHeaderActions {
flex-wrap: nowrap;
margin-left: auto;
min-width: 0;
gap: var(--sp-1);
}
#holdemHeaderActions .holdemBtn {
flex: 0 1 auto;
padding-left: var(--sp-2);
padding-right: var(--sp-2);
font-size: var(--fs-sm);
@@ -4845,6 +5282,14 @@ body.hasActionBar #toastHost {
right: var(--actionRailW);
border-top-right-radius: var(--r-lg);
}
/* The scrim stops where the sheet stops. Fold, Call and Raise live in the rail
down the right-hand side when a phone is on its side, and a scrim that reached
across it would be dimming — and swallowing the taps on — the one part of the
screen the sheet was deliberately kept clear of. */
#seatSheetScrim {
right: var(--actionRailW);
}
}
/* ---------- Focus is brought to rest where it can be seen ----------
@@ -277,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);
@@ -308,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);
}
@@ -609,12 +632,17 @@ const ActionBar = {
// 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),
null,
{ maxLayers: 4 }
{ maxLayers: 4, summarise: Betting.isRunningOut(this.state) }
);
},
@@ -1009,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();
}));
@@ -1140,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);
@@ -1169,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");
@@ -1184,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));
@@ -1216,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() {
+182 -21
View File
@@ -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
@@ -364,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
@@ -372,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 => {
@@ -769,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 => {
@@ -851,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");
@@ -1001,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
@@ -1014,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;
@@ -40,6 +77,40 @@ const Render = {
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() {
@@ -48,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 ?? "";
@@ -175,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
@@ -1748,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.
@@ -2001,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;
}
@@ -2016,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) {
@@ -2029,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
@@ -2149,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", () => {
@@ -2175,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);
});
@@ -2312,7 +2514,7 @@ const Render = {
// 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 } = {}) {
buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers, summarise } = {}) {
if (pots.length < 2 && returns.length === 0) return null;
const wrap = document.createElement("div");
@@ -2352,6 +2554,36 @@ const Render = {
wrap.appendChild(row);
};
// 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.
@@ -2799,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,
@@ -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;
+446
View File
@@ -571,6 +571,452 @@ narrowing fix has to be shown not to have blinded the thing it quietened, so the
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
+75
View File
@@ -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");
});
});
+98
View File
@@ -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 };
+141
View File
@@ -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");
});
});
+5 -1
View File
@@ -107,9 +107,13 @@ describe("the tokens themselves", () => {
// 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, 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",
"--toastHeight", "--seatSheetHeight",
]);
// Defined by the site's own stylesheet, not this one.
const fromSite = new Set(["--font-mono"]);
+6
View File
@@ -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,
+100
View File
@@ -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 };
+178
View File
@@ -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/);
});
});
+68
View File
@@ -159,3 +159,71 @@ describe("the wizard, which has room", () => {
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");
});
});
+282
View File
@@ -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");
});
});
+287
View File
@@ -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); });
+234
View File
@@ -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);
});
});
+66
View File
@@ -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);
});
});
+147
View File
@@ -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);
});
});
+175
View File
@@ -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/);
});
});
+89
View File
@@ -652,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 = [];
@@ -1018,6 +1035,77 @@ const LayoutAudit = {
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
@@ -1428,6 +1516,7 @@ const LayoutAudit = {
this.oneDealerButton(),
this.hiddenButPainted(),
this.emptyDecoration(),
this.oneNextStep(),
this.glancesFitTheirRows(),
this.seatLabelsClear(),
this.seatsKeepTheirText(),
+276
View File
@@ -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);
});
});