Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde65db45a | ||
|
|
55aceaed5b |
@@ -16,13 +16,10 @@
|
||||
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" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
|
||||
<a href="/casino" class="holdemBackLink">← Casino</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>
|
||||
@@ -34,20 +31,6 @@
|
||||
</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>
|
||||
@@ -114,14 +97,6 @@
|
||||
rather than what pressing it does to your evening. What it does is put a
|
||||
card sheet in front of you at four moments: each street, and the showdown. *@
|
||||
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Keep the screen on during a hand</span>
|
||||
<input type="checkbox" id="keepScreenAwakeToggle" />
|
||||
</label>
|
||||
@* On by default, and the only setting here that is. A dealer has to
|
||||
opt in to the tool doing more than they asked; they should not have
|
||||
to opt in to it staying readable. *@
|
||||
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks — and the tool says so the first time it takes hold.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
@@ -320,14 +295,6 @@
|
||||
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>
|
||||
@@ -354,20 +321,6 @@
|
||||
</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">
|
||||
@@ -384,45 +337,25 @@
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -434,14 +367,9 @@
|
||||
</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>
|
||||
@@ -777,8 +705,6 @@
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
|
||||
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -53,39 +53,6 @@ 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
|
||||
|
||||
@@ -57,31 +57,11 @@
|
||||
--line: #3a3a3a;
|
||||
--line-strong: #4a4a4a;
|
||||
|
||||
/* 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. */
|
||||
--text-bright: #f5f5f5;
|
||||
--text: #eee;
|
||||
--text-muted: #c8c8c8;
|
||||
--text-dim: #b4b4b4;
|
||||
--text-muted: #9a9a9a;
|
||||
--text-dim: #8a8a8a;
|
||||
|
||||
/* The one accent. Desaturated against the dark ground — a fully saturated gold
|
||||
vibrates on a near-black surface and stops reading as a highlight. */
|
||||
@@ -269,55 +249,19 @@ 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. */
|
||||
|
||||
/* 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. */
|
||||
/* Nobody should get a stuck press state from a tap that turned into a scroll. */
|
||||
@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),
|
||||
.actionBtn:active,
|
||||
.actionUndo:active:not(:disabled),
|
||||
.toastAction:active,
|
||||
.toastClose:active,
|
||||
.potWizardChoice:active {
|
||||
.actionUndo:active:not(:disabled) {
|
||||
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 ----------
|
||||
@@ -422,10 +366,16 @@ button,
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
.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;
|
||||
}
|
||||
|
||||
#holdemHeaderActions {
|
||||
@@ -739,23 +689,9 @@ 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(0, 0, 0, 0.34), rgba(0, 0, 0, 0.78));
|
||||
background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55));
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: var(--r-pill);
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
@@ -766,16 +702,11 @@ 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-bright);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
#potBadgeAmount {
|
||||
@@ -982,29 +913,14 @@ 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(18, 18, 18, 0.62);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
border-color: rgba(255, 255, 255, 0.13);
|
||||
box-shadow: none;
|
||||
/* Deliberately louder than seatInactive: an open seat is information a
|
||||
dealer wants, not something to squint at. */
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.seatEmptyLabel {
|
||||
@@ -1019,17 +935,16 @@ 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 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. */
|
||||
/* 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. */
|
||||
.seatChairNumber {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 6px;
|
||||
font-size: var(--fs-2xs);
|
||||
color: var(--text-muted);
|
||||
color: #aeaeae;
|
||||
}
|
||||
|
||||
/* Two lines, not an ellipsis.
|
||||
@@ -1213,28 +1128,34 @@ button,
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: opacity 0.15s ease;
|
||||
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
|
||||
But tapping it hands the button to that seat and re-posts the blinds, and 22px
|
||||
is half the 44px a fingertip needs. The hit area grows without the chip doing
|
||||
so: the visual stays 22px and an invisible pad around it catches the tap.
|
||||
Deliberately not centred on the chip — it's pushed down and left, away from the
|
||||
neighbouring seat the chip juts towards, so the bigger target can't start
|
||||
stealing taps meant for the seat next door. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* There is no invisible pad around this chip any more, and that is the point.
|
||||
|
||||
It was here to make a 22px disc meet the 44px a fingertip needs. It was moved twice
|
||||
trying to find somewhere to grow that wasn't already spoken for — outward first,
|
||||
where it took taps from the seat next door; then inward, over "its own seat, which
|
||||
is a large target that can spare the corner".
|
||||
|
||||
The seat could not spare it. Measured on a 390px phone, a 72x46 puck: the pad ran
|
||||
from the puck's own centre to its right edge and from its top to 3px off its bottom,
|
||||
and the chip's click handler calls stopPropagation. The seat holding the button kept
|
||||
52% of its face — 130 of 276 sampled points went to the chip, its exact middle among
|
||||
them. Tapping the button seat to open its editor did nothing at all, and on a fresh
|
||||
table the button is on seat 1, so the first thing a dealer touches is the one dead
|
||||
puck on the felt.
|
||||
|
||||
The pad had no user left to serve. A finger never reaches this chip: on touch the
|
||||
ghost chips are pointer-events: none (below), and the one live chip is the seat that
|
||||
already has the button — see .dealerChip.active. Giving the button on touch has
|
||||
always been the seat's long-press menu. What remains is a mouse on a revealed ghost,
|
||||
and 22px is a mouse target with room to spare. */
|
||||
.dealerChip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* Grows inward, over its own seat, not outward onto the neighbours.
|
||||
|
||||
The chip already sits outside its seat by design — top:-10px, right:-10px — so
|
||||
a pad that also reaches up and right lands on whatever seat is next around the
|
||||
oval. It did: three of nine seats had taps taken by a neighbour's chip, seat 1
|
||||
losing 10 of 49 sampled points including its own centre, which meant tapping
|
||||
the middle of seat 1 gave the button to seat 9.
|
||||
|
||||
Down and left is its own seat, which is a large target that can spare the
|
||||
corner. The two pixels of outward reach are just to cover the chip's border. */
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
bottom: -18px;
|
||||
left: -18px;
|
||||
}
|
||||
|
||||
/* A poker table has one dealer button. This one drew nine.
|
||||
|
||||
@@ -1270,25 +1191,11 @@ button,
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The one that has the button is a marker, not a control.
|
||||
|
||||
Every other chip on the felt answers "give the button to this seat". This one would
|
||||
answer "give the button to the seat that has it" — which is nothing, except mid-hand,
|
||||
where makeDealer takes the blinds back and re-posts them. So it was a puck that either
|
||||
did nothing or did something unasked-for, sitting on top of the seat a dealer was
|
||||
reaching for. The seat menu already knew this and hides "Give the button" on the seat
|
||||
that holds it; the chip now agrees.
|
||||
|
||||
Inert to the pointer, still there to read. Its label says "<name> has the dealer
|
||||
button" — a statement, and the only place on the page that says it — so it keeps its
|
||||
place in the tree and under the keyboard. */
|
||||
.dealerChip.active {
|
||||
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
||||
border-color: var(--gold-deep);
|
||||
color: #3a2e00;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dealerChip.hidden {
|
||||
@@ -1389,13 +1296,9 @@ 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, #f0c069, #e2a845);
|
||||
background: linear-gradient(180deg, #e6ad4d, #c98a2a);
|
||||
color: #241a05;
|
||||
}
|
||||
|
||||
@@ -1455,17 +1358,6 @@ 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
|
||||
@@ -1522,11 +1414,10 @@ button,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/* Grip and Done button are the sheet's furniture — no reason for either on a
|
||||
desktop sidebar, where the panel is just a panel. */
|
||||
.seatSheetGrip,
|
||||
.seatSheetClose,
|
||||
#seatSheetScrim {
|
||||
.seatSheetClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1545,66 +1436,6 @@ 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);
|
||||
@@ -2311,21 +2142,15 @@ 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: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
@@ -2334,32 +2159,17 @@ 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);
|
||||
@@ -3088,7 +2898,7 @@ body.hasActionBar #holdemApp {
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid #414141;
|
||||
background: #262626;
|
||||
color: var(--text-muted);
|
||||
color: #bdbdbd;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
@@ -3105,16 +2915,6 @@ 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;
|
||||
@@ -3129,7 +2929,7 @@ body.hasActionBar #holdemApp {
|
||||
}
|
||||
|
||||
.actionPlayers {
|
||||
color: var(--text-muted);
|
||||
color: #9c9c9c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -3141,7 +2941,7 @@ body.hasActionBar #holdemApp {
|
||||
|
||||
.actionWho {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
color: #9c9c9c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -3453,7 +3253,7 @@ body.hasActionBar #holdemApp {
|
||||
padding: 0 var(--sp-1);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0.9;
|
||||
opacity: 0.75;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-2xs);
|
||||
line-height: 1.4;
|
||||
@@ -3464,12 +3264,7 @@ 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.
|
||||
|
||||
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. */
|
||||
read under the threshold. They're still quieter than the label they sit beside. */
|
||||
.actionKey {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@@ -3477,7 +3272,7 @@ body.hasActionBar #holdemApp {
|
||||
padding: 0 var(--sp-1);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0.9;
|
||||
opacity: 0.75;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-2xs);
|
||||
line-height: 1.4;
|
||||
@@ -3644,38 +3439,11 @@ 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);
|
||||
color: var(--text-muted);
|
||||
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
|
||||
@@ -3784,33 +3552,6 @@ 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;
|
||||
@@ -4416,92 +4157,26 @@ 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 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
gap: var(--sp-1);
|
||||
width: 100%;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* 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-2);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||
@@ -4542,50 +4217,6 @@ 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;
|
||||
@@ -4670,20 +4301,6 @@ 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);
|
||||
}
|
||||
@@ -4801,10 +4418,23 @@ body.hasActionBar #toastHost {
|
||||
padding: 0 var(--sp-2);
|
||||
}
|
||||
|
||||
/* The dealer chip's 44px tap pad used to be redeclared here, centred on the disc.
|
||||
On a 72x46 puck that square started at the puck's own middle, and the seat that
|
||||
had the button could not be tapped where a thumb lands. It is gone; see
|
||||
.dealerChip near the top of this file for why nothing needs to replace it. */
|
||||
/* The dealer button has to stay a 22px disc — it's a chip sitting on a seat, and
|
||||
drawing it thumb-sized would swallow the puck. So the disc keeps its size and
|
||||
an invisible pad around it takes the tap. It reaches outward, away from the
|
||||
puck's own tap target, so pressing the seat still opens the seat. */
|
||||
.dealerChip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.dealerChip {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.toastAction {
|
||||
min-height: 44px;
|
||||
@@ -4820,30 +4450,6 @@ 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%
|
||||
@@ -4907,12 +4513,8 @@ 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 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. */
|
||||
reader and for the browser's own outline. The hand number stays — nothing else on
|
||||
the screen says which hand this is. */
|
||||
#holdemHeader {
|
||||
margin-bottom: var(--sp-2);
|
||||
gap: var(--sp-1);
|
||||
@@ -4920,10 +4522,6 @@ body.hasActionBar #toastHost {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#holdemHeaderLeft {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -4935,28 +4533,17 @@ body.hasActionBar #toastHost {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backLinkWord {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.holdemBackLink {
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
.handCounterBadge {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
@@ -5277,14 +4864,6 @@ 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,29 +277,10 @@ 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);
|
||||
@@ -327,12 +308,8 @@ const ActionBar = {
|
||||
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
||||
const runout = document.createElement("span");
|
||||
runout.className = "actionRunout";
|
||||
// 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";
|
||||
const stillIn = Betting.liveSeats(this.state).length;
|
||||
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
||||
row.appendChild(runout);
|
||||
}
|
||||
|
||||
@@ -632,17 +609,12 @@ 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, summarise: Betting.isRunningOut(this.state) }
|
||||
{ maxLayers: 4 }
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1037,7 +1009,7 @@ const ActionBar = {
|
||||
if (!field) return;
|
||||
const panel = field.closest("details");
|
||||
if (panel) panel.open = true;
|
||||
Render.bringIntoView(field, { block: "center" });
|
||||
field.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
field.focus();
|
||||
field.select();
|
||||
}));
|
||||
@@ -1168,11 +1140,6 @@ 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);
|
||||
|
||||
@@ -5,32 +5,6 @@ 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
|
||||
@@ -246,11 +220,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
makeDealer: index => {
|
||||
// Moving the button to where it already is is not a move. Saying so costs a
|
||||
// toast nobody asked for, and mid-hand it would take the blinds back off the
|
||||
// felt and re-post the same chips to say nothing happened.
|
||||
if (index === state.dealerSeatIndex) return;
|
||||
|
||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
@@ -395,7 +364,6 @@ 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
|
||||
@@ -404,7 +372,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
Render.bringIntoView(panel, { block: "nearest" });
|
||||
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
},
|
||||
|
||||
beginAction: label => {
|
||||
@@ -801,27 +769,6 @@ 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 => {
|
||||
@@ -904,7 +851,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// costs a screen reader the sentence.
|
||||
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||
jump.addEventListener("click", () => {
|
||||
Render.bringIntoView(section, { block: "start" });
|
||||
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
// 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");
|
||||
@@ -1054,74 +1001,6 @@ 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
|
||||
@@ -1135,78 +1014,33 @@ 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 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 () => {
|
||||
const registerWorker = () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const shell = [
|
||||
location.pathname,
|
||||
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),
|
||||
"/casino/holdem.webmanifest",
|
||||
...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href),
|
||||
...[...document.querySelectorAll("script[src]")].map(el => el.src),
|
||||
].filter(url => new URL(url, location.origin).origin === location.origin);
|
||||
|
||||
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();
|
||||
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.
|
||||
});
|
||||
} 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,29 +156,6 @@ 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,46 +20,9 @@ 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;
|
||||
@@ -77,40 +40,6 @@ 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() {
|
||||
@@ -119,7 +48,6 @@ 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 ?? "";
|
||||
@@ -1370,11 +1298,6 @@ const Render = {
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
node.querySelector(".dealerChip").addEventListener("click", e => {
|
||||
// The seat that already has the button has nothing to give itself, and
|
||||
// this handler swallows the event — so a chip that answered here would
|
||||
// be a hole in its own seat. CSS keeps the pointer off it; the keyboard
|
||||
// can still reach it, and this is what it finds.
|
||||
if (index === this.state.dealerSeatIndex) return;
|
||||
e.stopPropagation();
|
||||
this.hooks.onGiveButton(index);
|
||||
});
|
||||
@@ -2147,10 +2070,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -2166,52 +2085,8 @@ 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) {
|
||||
@@ -2223,11 +2098,6 @@ 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
|
||||
@@ -2348,12 +2218,7 @@ const Render = {
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".chipSwatch").style.background = chip.color;
|
||||
// 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);
|
||||
row.title = `$${chip.value} chips`;
|
||||
const input = row.querySelector(".chipCountInput");
|
||||
input.value = player.chipCounts[chip.id] || 0;
|
||||
input.addEventListener("input", () => {
|
||||
@@ -2379,18 +2244,15 @@ 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);
|
||||
});
|
||||
@@ -2519,7 +2381,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, summarise } = {}) {
|
||||
buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers } = {}) {
|
||||
if (pots.length < 2 && returns.length === 0) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
@@ -2559,36 +2421,6 @@ 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.
|
||||
@@ -3036,6 +2868,8 @@ 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,11 +66,6 @@ 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,
|
||||
@@ -234,12 +229,6 @@ 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
|
||||
@@ -329,48 +318,6 @@ 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,
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// 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;
|
||||
@@ -621,402 +621,6 @@ still names whoever didn't turn over; closing it leaves **Showdown — resolve p
|
||||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||||
is computed from the cards on the table rather than remembered from the last attempt.
|
||||
|
||||
## A scroll was an edit, and it reached the saved table
|
||||
|
||||
The worst bugs in this tool are the ones that move money without saying so, and this one
|
||||
was triggered by reaching for another panel.
|
||||
|
||||
A focused `<input type="number">` is a spinbutton, and a browser lets the mouse wheel
|
||||
drive it. Measured on the running page at 1280x900 with the sidebar open:
|
||||
|
||||
| gesture | before | after | saved? |
|
||||
|---|---|---|---|
|
||||
| focus the small blind, scroll up | $5 | **$5.50** | yes |
|
||||
| focus a seat's stack, scroll up | $500 | **$501** | yes |
|
||||
|
||||
Both go straight through to `localStorage`, because these fields save on `input` like
|
||||
everything else here. Nothing is announced, nothing looks wrong, and the tool has
|
||||
invented a chip — on a table where the whole point is that the chip count is right.
|
||||
|
||||
The rule that fixes it is narrow on purpose. Every *other* way of driving a spinbutton is
|
||||
deliberate: the arrow keys, the stepper arrows, typing. Scrolling is the one actuation
|
||||
nobody means, so it is the only one taken away. Verified after the change: the value
|
||||
holds, one ArrowUp still moves it, typing still works, **and the page still scrolls** —
|
||||
0 to 300px, which matters, because the obvious fix does not.
|
||||
|
||||
### Why it blurs instead of cancelling
|
||||
|
||||
`preventDefault()` on the wheel stops the value moving and stops the page moving with it.
|
||||
That turns a silent bug into a sidebar that mysteriously refuses to scroll, which is a
|
||||
worse trade: the dealer now has a problem they can see and can't explain, instead of one
|
||||
they can't see. Dropping focus lets the scroll do exactly what was asked and leaves the
|
||||
number alone.
|
||||
|
||||
### One guard that was checked and found to be decoration
|
||||
|
||||
The predicate starts with `field.tagName !== "INPUT"`. Injecting a fault into it — taking
|
||||
it out entirely — changed nothing: every test still passed, because nothing but an
|
||||
`<input>` carries `type="number"`, so the type check below it already excludes every
|
||||
textarea, select and button on the page.
|
||||
|
||||
That is written into the comment rather than quietly kept. A guard that cannot fail is
|
||||
fine to leave in for symmetry with `selectsOnFocus` beside it; a guard that cannot fail
|
||||
and is *presented* as load-bearing is how the next person wastes an afternoon.
|
||||
|
||||
The page's one `<select>` — which way the blinds move — was the obvious next suspect and
|
||||
is not one: focused, hovered and scrolled 300px in Chromium it stays on "clockwise". So
|
||||
it is left out of the rule rather than defended against, and the check keeps meaning
|
||||
exactly one thing.
|
||||
|
||||
## The motion setting was honoured in the three places it mattered least
|
||||
|
||||
The stylesheet had answered `prefers-reduced-motion` for a while. All three places it did
|
||||
were the wrong size of thing: a 2px press-down on a button, a checkbox's tick, and the
|
||||
acting seat's pulse. What none of them covered was motion that moves something *across
|
||||
the screen* — and for anyone with a vestibular disorder that is the part that matters. A
|
||||
button dipping under a thumb is not what makes people ill.
|
||||
|
||||
Four things were doing it, and the three largest were in JavaScript, where a stylesheet
|
||||
cannot reach:
|
||||
|
||||
- the seat editor, a bottom sheet that slides the full height of itself up from
|
||||
off-screen every time a dealer taps a chair, which on a busy table is most of the taps
|
||||
they make all night;
|
||||
- the toast, sliding in from below;
|
||||
- and three `scrollIntoView({ behavior: "smooth" })` calls — the most provocative of the
|
||||
lot, because what moves is the whole viewport rather than one control.
|
||||
|
||||
The scroll calls now go through `Render.bringIntoView`, which reads the setting per call
|
||||
rather than at page load: a dealer who turns it on mid-session should be taken seriously
|
||||
without reloading, and the table is a thing they cannot afford to reload lightly.
|
||||
|
||||
The toast keeps its fade and loses its travel. Deleting the animation outright is the
|
||||
wrong answer — something that appears already in place reads as a rendering glitch rather
|
||||
than as news — and the guidance that says to cut movement says to keep *something*.
|
||||
|
||||
The sheet needed one more thing than the obvious. Its `visibility` transition carries a
|
||||
0.25s delay so the slide-out stays on screen for its own animation; with the slide gone,
|
||||
that same delay leaves a sheet that has already jumped away still painted for a quarter
|
||||
of a second. It flips at once under reduced motion, in both directions.
|
||||
|
||||
### A list whose membership was arbitrary, because it was solving another problem
|
||||
|
||||
The press-down block held nine `:active` selectors and looked deliberate. It wasn't. It
|
||||
had been written to stop a *stuck press state* when a tap turns into a scroll, and it
|
||||
reached for the reduced-motion query as the nearest available proxy — so it covered
|
||||
`.dealerChip` and `.raiseQuickBtn` while missing `.actionBtn`, the class on Fold, Call,
|
||||
Raise and Post blinds & deal, which is to say the most-tapped control in the tool.
|
||||
|
||||
Read as its own rule, membership is not a judgement call: **every `:hover` and `:active`
|
||||
transform in the file belongs there.** There are sixteen. Nine were listed. The other
|
||||
seven are now.
|
||||
|
||||
### The scan that found the last three, and the injection that found the scan
|
||||
|
||||
The general test scans the stylesheet for pointer transforms and fails on any without an
|
||||
escape, so a new one cannot be added without the block being updated. It took two goes,
|
||||
and both faults are worth keeping written down.
|
||||
|
||||
The first version scanned line by line: remember the last line ending in `{`, then look
|
||||
for lines starting with `transform:`. A deliberately broken version was run at it — a new
|
||||
hover lift, uncovered — and the scan **passed**. The rule had been written on one line,
|
||||
so the selector and the declaration were in the same string and neither pattern matched.
|
||||
|
||||
Rewritten to parse blocks, it caught that. It also immediately failed on the *clean*
|
||||
file, which is how the last three came to light: a multi-line comma-separated group had
|
||||
been truncated to its final line by the old scan, so `.toastAction`, `.toastClose` and
|
||||
`.potWizardChoice` had never been looked at by anything. A grouped rule is nine separate
|
||||
promises, and it is now split on commas and checked one at a time — comparing the joined
|
||||
string against the block would only have passed if both lists happened to be written in
|
||||
the same order.
|
||||
|
||||
## Two pixels, and every seat on the felt paid for them
|
||||
|
||||
The sweep had carried the same residual for several rounds: on the two smallest phones
|
||||
with twelve players, seats painted over each other's names and stacks. It read like a
|
||||
geometry problem — twelve pucks, not enough rim — and the layout code had already been
|
||||
round that loop twice, deriving the ring from the felt and searching for a puck width
|
||||
that clears.
|
||||
|
||||
It was a wrapped label.
|
||||
|
||||
`"ALL IN"` is the only thing that ever lands on the stack line with a space in it. A
|
||||
stack figure has no spaces, so it can only overflow, and the `puckAmountLong` and
|
||||
`puckAmountHuge` sizes exist to stop it doing that. Nothing had ever asked what the line
|
||||
does when it *can* break. Measured at 360x640 with twelve players all-in: it wanted 36px
|
||||
and had 34, so it broke onto a second line, and an 11px line became 22.
|
||||
|
||||
That is not a typography complaint, which is why it survived so long — it looks fine.
|
||||
The puck went from 55px tall to 66, and puck height is precisely what the ring is short
|
||||
of. At the ends of an ellipse consecutive seats are separated *vertically*, so a puck
|
||||
11px taller is a puck 11px further into its neighbour. The layout search then did exactly
|
||||
what it is written to do: tried every width it has, found none that cleared, and fell
|
||||
back to the narrowest — which, being the narrowest, is also the one that wraps hardest.
|
||||
|
||||
So the fix is the rule every design system already gives a badge: one line, always.
|
||||
|
||||
### Not wrapping is only half an answer
|
||||
|
||||
A line that cannot wrap and still does not fit paints over the seat next door instead —
|
||||
the same failure, arriving sideways. So the mark has to actually *fit* 34px, and it
|
||||
wanted 36.
|
||||
|
||||
Of the three things that separate "ALL IN" from a grey money figure at a glance — a warm
|
||||
colour, a bold weight, and wider letters — the letter-spacing is the one that costs
|
||||
pixels: 3.6px of the 36. On the tight tier it goes, and the other two stay. Measured
|
||||
after: 33px of ink in a 34px box on a Galaxy S8, and the full spacing everywhere else,
|
||||
because no other puck is short of the room.
|
||||
|
||||
The three of them are pinned in `allInMarker.test.js` against the stylesheet itself —
|
||||
including the assertion that the *ordinary* spacing still doesn't fit, so that if someone
|
||||
later widens the puck and makes the whole problem moot, the test says it is measuring the
|
||||
wrong thing rather than passing quietly.
|
||||
|
||||
### And the bar was standing on the felt while nobody could act
|
||||
|
||||
Halving the overlap was not clearing it, and the rest of the shortage was not on the
|
||||
felt at all. The sweep had been saying so for rounds, in a warning rather than a failure:
|
||||
at 360x640 with twelve players all-in the action bar was **267px of a 640px window — 42%**,
|
||||
against its own "persistent chrome under 30%".
|
||||
|
||||
Two things in it were being shown at a moment when they could not be used.
|
||||
|
||||
The **side-pot list** is decision support. A player working out whether to call is owed
|
||||
the side pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
Once everyone is all-in there is no decision left to support — the only thing that
|
||||
happens for the rest of the hand is the dealer turning cards — and the list went on
|
||||
standing there. During a run-out it now collapses to the main pot, which is the number a
|
||||
dealer says out loud, and one line for everything else, which is what the pot wizard is
|
||||
for and what that row has always said in as many words.
|
||||
|
||||
The **status row** said the player count twice in the same breath: a `12 players` chip,
|
||||
and then `All-in — running out the board (12 players)` immediately to its right. That
|
||||
reads like two different figures being quoted, and on a 360px phone it cost the sentence
|
||||
a second line.
|
||||
|
||||
Measured across the three, on the table shape with the most seats to fit:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| Bar, Galaxy S8 | 267px (42%) | 249px (39%) |
|
||||
| Bar, iPhone SE | 267px (40%) | 227px (34%) |
|
||||
| Bar, Pixel 7 | 267px (32%) | 227px (25%) |
|
||||
| Felt height, Galaxy S8 | 192px | 210px |
|
||||
| Felt height, iPhone SE | 243px | 283px |
|
||||
| Worst seat overlap, iPhone SE | 34px | 4px |
|
||||
| Worst seat overlap, Galaxy S8 | 34px | 17px |
|
||||
|
||||
The summary row is held to the same rule as the elision above it, and it is the rule that
|
||||
matters most for anything that hides a number: **it must not lose a chip.** A dealer
|
||||
counting the announcement against the chips in the middle of the table must not find a
|
||||
shortfall the tool invented, so every row count is checked to add up to the pots it
|
||||
stands for, at every table size from three players to twelve.
|
||||
|
||||
### What is left, and why it is a decision rather than a fix
|
||||
|
||||
**315 of 320 audits pass.** The five that don't are all one root cause and all on the two
|
||||
smallest screens in the sweep:
|
||||
|
||||
- `Galaxy S8 · all in` (360x640), twelve players, every one of them all-in at once
|
||||
- `Galaxy S8 · packed · raise pad` (360x640), twelve players with the pad open
|
||||
- `iPhone SE · all in` (375x667), twelve all-in, on the mouse pass only
|
||||
|
||||
The twelve-handed table at 360x640 now passes with the betting live. It fails in the two
|
||||
states that make the bar tallest, and there the arithmetic is simply short: the ring has
|
||||
about 44px of rim per seat and a puck's footprint is 60. No width in the search clears
|
||||
that, which is why `resolveSeatLayout` has a documented fallback instead of pretending —
|
||||
it keeps the narrowest puck on the widest ring and lets this sweep say so out loud.
|
||||
|
||||
The remaining chip offenders are the same shortage seen from the other layer.
|
||||
`anchoredChipTravel` walks a chip back off a stranger's puck a pixel at a time, and stops
|
||||
at its own puck's edge because there is nowhere further to go. When the pucks themselves
|
||||
overlap, travel reaches zero and the chip is still under a neighbour.
|
||||
|
||||
Getting the last five to pass is a design decision and not another measurement: dropping
|
||||
the bet chips at extreme crowding the way the tight tier already drops the hole cards,
|
||||
capping the seat count by viewport, or letting the felt scroll. Each trades something a
|
||||
dealer can see, so none of them should be picked by whoever is next holding a ruler.
|
||||
|
||||
## The contrast check was passing text nobody could read
|
||||
|
||||
The layout sweep has checked WCAG AA contrast for a long time and the page passed it.
|
||||
That result was worth less than it looked, for two separate reasons.
|
||||
|
||||
The first is the instrument. WCAG 2's contrast ratio is a luminance gap, and a luminance
|
||||
gap overstates readability once one of the two colours is near black — which is every
|
||||
reading taken on this page, whose ground is `#141414` and whose panels are darker than
|
||||
its rows. This is not a quibble about the spec; it is the spec's own working group's
|
||||
position, and it is why APCA exists. Tuning a colour until it clears 4.5:1 on a
|
||||
near-black ground lands on text that passes the audit and still reads as a smudge across
|
||||
a table in a dim room. Several rules in the stylesheet said so in their own comments —
|
||||
one of them "measured 4.5:1 at `#a5a5a5` exactly, so it sits above that" — which is the
|
||||
right instinct aimed through the wrong lens.
|
||||
|
||||
The second is that the check could not see half the backgrounds it was scoring against.
|
||||
It reads `background-color` and walks up the ancestors, so a background painted by a
|
||||
gradient is invisible to it; and where several ancestors are candidates it takes *the
|
||||
best* contrast of any of them. Put together, text on the felt was being scored against
|
||||
the dark page behind the felt rather than the felt. Two plain AA failures had been
|
||||
sitting in a clean report for as long as the check has existed: the chair number on an
|
||||
empty seat, on the table's brown rim, at 2.65:1, and the pot's own label, gold on green,
|
||||
at 2.66:1.
|
||||
|
||||
`runContrastAudit.js` is the answer to both. It scores with APCA, and it gets the
|
||||
background by screenshotting the page with every piece of text made transparent and
|
||||
reading the pixels underneath — which cannot be fooled by a gradient, a shadow or a
|
||||
border, because it is looking at what was painted. The first version of it *did* walk the
|
||||
DOM, and reported the gold dealer button as black-on-black at Lc 0.4; an element anyone
|
||||
can see is legible. That false positive is why the tool works the way it does.
|
||||
|
||||
What it found, on 92 distinct pieces of text: 65 below the level APCA sets for the size
|
||||
and weight they are drawn at, **54 of them passing WCAG AA at the same moment**. The
|
||||
worst was Lc 28. After this round nothing on the page is below Lc 60, nothing fails AA,
|
||||
and the worst reading is Lc 60.4.
|
||||
|
||||
Three of the fixes are worth naming, because none of them is "make the grey lighter":
|
||||
|
||||
- **A player's stack was styled as secondary text.** Muted grey at Lc 44, under a name
|
||||
set in `--text` at a heavier weight. Read back the other way that says a dealer needs
|
||||
the name more than the number, and during a hand the opposite is true — the name is
|
||||
looked up once to find the chair, the stack is read on every action because it decides
|
||||
whether a player can cover the bet. Hierarchy now comes from size and weight, which is
|
||||
free, instead of from fading, which on a dark ground is the most expensive way to say
|
||||
"secondary".
|
||||
- **An empty seat had no background of its own**, just a 2%-white wash at 0.7 opacity, so
|
||||
whatever the felt was doing behind it decided how readable its number was: Lc 28 on the
|
||||
rim, 30 on the dark green, 33 further in. Contrast that depends on where a seat happens
|
||||
to sit cannot be reasoned about. It has a ground now. The wash is 0.62 and not the 0.82
|
||||
it was first written at, because contrast turns out to be nearly flat across that range
|
||||
— two points of Lc — and the heavier one put four dark rectangles on the felt like
|
||||
holes.
|
||||
- **The pot's label sat on the one green in the design.** `--gold` is tuned for near-black
|
||||
and measures Lc 70-odd everywhere else it is used; on the pot disc it was Lc 38. The
|
||||
disc is deeper now rather than the label bleached, because gold on dark green is the
|
||||
pairing the whole page is built on. Deepening only the disc's outer stop moved it two
|
||||
points and no further — the highlight is at 32%/28% and that is exactly where the label
|
||||
sits, so the reading was being taken on the brightest part of the disc.
|
||||
|
||||
### What is deliberately not fixed
|
||||
|
||||
APCA asks Lc 90 of anything at 12px/400 or below, and this page is full of 10px and 11px
|
||||
labels that live inside a 58px seat puck. Fifty-five pieces of text are still below that
|
||||
bar. Reaching it means white micro-labels and a flatter, shoutier page; the honest way to
|
||||
clear it is bigger type, and bigger type is a layout change that would relitigate the
|
||||
seat-crowding work above. So the sweep reports two numbers — the strict per-size level,
|
||||
and the Lc 60 / Lc 75 floors this page actually holds itself to — and only the floors are
|
||||
treated as pass/fail.
|
||||
|
||||
## The failures that only happen at the table
|
||||
|
||||
Everything above this line is about a tool being easy to read and quick to press. This
|
||||
section is about it being *there* — the three ways it could let a dealer down on the
|
||||
night that no amount of layout work would have caught, because none of them show up on a
|
||||
developer's desk with a good connection and a plugged-in laptop.
|
||||
|
||||
### One spelling of the URL, because the offline promise depends on it
|
||||
|
||||
The manifest says "Works with no signal", and it did — at `/casino/texasholdem`.
|
||||
|
||||
Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical
|
||||
page. A service worker's scope is not: the spec matches it as a plain string prefix, so a
|
||||
worker scoped to `/casino/` does not control a page at `/Casino/TexasHoldem`. Measured on
|
||||
the capital-C spelling, with the network cut:
|
||||
|
||||
| | registration | controlling | cached | offline |
|
||||
|---|---|---|---|---|
|
||||
| `/casino/texasholdem` | created | yes | 25 entries | loads |
|
||||
| `/Casino/TexasHoldem` | created, active | **no** | **nothing at all** | `ERR_INTERNET_DISCONNECTED` |
|
||||
|
||||
The empty cache is the part that matters, and it took a second look to understand. The
|
||||
registration *succeeded*. The worker *activated*. Nothing was logged. But `registerWorker`
|
||||
handed its precache list to `navigator.serviceWorker.ready`, and `ready` resolves with the
|
||||
registration **controlling this page** — so on a page outside the scope it never resolves,
|
||||
the message is never sent, and not one file is stored. A dealer who had opened that URL to
|
||||
"load the tool for tonight" had loaded nothing, and had no way to find out until the
|
||||
signal went.
|
||||
|
||||
So one spelling wins: a permanent redirect in `Program.cs`, scoped to `/casino` rather
|
||||
than applied site-wide, because every file under `wwwroot/casino` and
|
||||
`wwwroot/images/casino` is already lowercase and so lowercasing a path there cannot miss a
|
||||
real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the
|
||||
site, and `/chessHub` in particular would break.
|
||||
|
||||
The precache handshake was fixed too, rather than left to be saved by the redirect. It
|
||||
now takes the worker off the registration itself — `installing || waiting || active` —
|
||||
and waits for `activated` on that. A tool whose offline support silently depends on being
|
||||
in scope is one URL away from having none.
|
||||
|
||||
### And what the document never mentions
|
||||
|
||||
The shell list is read off the DOM on purpose, so it can't drift from the markup. The
|
||||
icons are the one part of the shell the DOM doesn't mention: the browser reads them out of
|
||||
the manifest, so no tag in the document points at them and the sweep missed all four. An
|
||||
installed app launched with no signal had no icon to show for itself.
|
||||
|
||||
They're read out of the manifest now — not typed into a list here, for exactly the same
|
||||
reason. iOS is the exception: it reads none of the manifest's icons and only
|
||||
`<link rel="apple-touch-icon">`, which *is* in the document, so the sweep picks up the
|
||||
stylesheet, icon and apple-touch links together. Twenty-five entries, all four icons.
|
||||
|
||||
Reading the manifest offline is deliberately cache-first, which is backwards from how the
|
||||
rest of the worker thinks. A plain `fetch` for it failed on every offline launch and
|
||||
logged `ERR_INTERNET_DISCONNECTED` — noise on the one screen where a real error has to be
|
||||
visible, in exchange for re-reading four paths that were already cached.
|
||||
|
||||
### A lock is easy to take and easy to leave holding
|
||||
|
||||
A dealer's phone lies face-up on the felt all night, and dims and locks between the flop
|
||||
and the turn every hand. The Screen Wake Lock API fixes that in about four lines, and the
|
||||
risk runs entirely the other way: a lock that is taken and *not* dropped burns the battery
|
||||
through a break, a re-buy, and twenty minutes of arguing about pizza, with nothing on
|
||||
screen to say why.
|
||||
|
||||
So the rule is narrow — held only while betting is actually open — and the tests are
|
||||
mostly about letting go:
|
||||
|
||||
- **A whole street of renders takes exactly one.** `sync()` runs off `renderAll`, which
|
||||
fires many times a second while a street is being bet.
|
||||
- **Two syncs in the same tick take exactly one.** Without the `pending` guard both get
|
||||
past the "already holding one?" check and the second sentinel is never stored, so
|
||||
nothing can release it.
|
||||
- **A hand that ends inside the request leaves nothing held.** A fold can end the hand
|
||||
between asking for a lock and being handed one, and the sentinel that arrives afterwards
|
||||
belongs to a hand that no longer exists.
|
||||
- **The browser taking it back leaves us able to take it again.** This is not an edge
|
||||
case: the browser releases the lock itself every time the document stops being visible,
|
||||
and never gives it back. A tool that takes one at the start of the hand and assumes it
|
||||
still has it works until the dealer checks a rule on their phone.
|
||||
|
||||
#### A fake that wasn't installed, and four tests that agreed
|
||||
|
||||
The first run of these had eight failures and, worse, one pass: "a browser without the API
|
||||
is left alone entirely" was green from the start.
|
||||
|
||||
`globalThis.navigator = fake` does nothing. Node has had its own `navigator` since v21,
|
||||
defined as a getter, so the assignment silently fails — no error, no effect. Every test in
|
||||
the file was measuring Node's real navigator, which has no `wakeLock`, so the acquire
|
||||
tests failed honestly and the unsupported-browser test passed for precisely the wrong
|
||||
reason. `Object.defineProperty` installs it properly.
|
||||
|
||||
Then five faults were injected and all five were caught: dropping the `pending` guard,
|
||||
keeping a lock the hand no longer wants, ignoring the browser's own release, holding it
|
||||
between hands, and reading a missing `keepScreenAwake` as off instead of on.
|
||||
|
||||
### Asking the browser to keep the night
|
||||
|
||||
"The night lives in this browser and nowhere else" was always true and always missing its
|
||||
second half. `localStorage` is evictable: a browser is free to clear site data when the
|
||||
device runs short, and Safari deletes script-created storage after seven days without a
|
||||
visit. A table saved at last month's game can simply not be there at this one, with
|
||||
nothing having gone wrong and nobody having deleted anything.
|
||||
|
||||
`navigator.storage.persist()` is the way to say this data is not that, and every browser
|
||||
wants the request to come from something the dealer did — so it fires on **Post blinds &
|
||||
deal**, the moment this stops being a setup screen and starts being a night somebody could
|
||||
lose. Chromium and Safari answer silently on their own engagement heuristics; Firefox
|
||||
asks. A no is not an error worth reporting at a table, so the only thing that changes is
|
||||
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -4,23 +4,9 @@
|
||||
// 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;
|
||||
@@ -110,64 +96,3 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// 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 };
|
||||
@@ -1,141 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -107,13 +107,9 @@ 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", "--seatSheetHeight",
|
||||
"--toastHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
|
||||
@@ -38,11 +38,6 @@ 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
|
||||
@@ -98,7 +93,6 @@ module.exports = {
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// 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 };
|
||||
@@ -1,178 +0,0 @@
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
@@ -159,71 +159,3 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -1,287 +0,0 @@
|
||||
// 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); });
|
||||
@@ -1,234 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
// The seat holding the dealer button has to still be a seat you can tap.
|
||||
//
|
||||
// On a fresh table the button is on seat 1, and seat 1 is where the first player goes.
|
||||
// Measured on a 390px phone, a 72x46 puck: the dealer chip's invisible tap pad ran from
|
||||
// the puck's own centre to its right edge and from its top to 3px off its bottom. The
|
||||
// chip's click handler calls stopPropagation, so every tap that landed there reached
|
||||
// nothing at all — the seat holding the button kept 52% of its own face, 130 of 276
|
||||
// sampled points going to a chip whose only answer was "give the button to the seat
|
||||
// that has it". Seating the first player, on the seat a dealer reaches for first, did
|
||||
// nothing. Every other seat kept 97%, and the button moves every hand, so the dead puck
|
||||
// moved around the table with it.
|
||||
//
|
||||
// Three things had to agree for that to happen and they are the three checked here: the
|
||||
// pad exists, the chip under it is live, and its handler eats the event. The pad is gone
|
||||
// (nothing needs it — no finger can reach any chip), the chip that has the button is
|
||||
// inert to the pointer, and the handler bails rather than swallowing a tap it has no
|
||||
// answer for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const src = (...parts) => fs.readFileSync(path.join(__dirname, "..", "..", "JoshHeaps.Net", ...parts), "utf8");
|
||||
|
||||
const CSS = src("wwwroot", "css", "casino", "holdem.css");
|
||||
const RENDER = src("wwwroot", "js", "casino", "holdem", "render.js");
|
||||
const MAIN = src("wwwroot", "js", "casino", "holdem", "main.js");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
|
||||
// The body of every rule written at exactly this selector, concatenated. Every one of
|
||||
// them, because the phone block redeclared the chip's pad under the same name and it
|
||||
// was the redeclared copy — 44px centred on the disc — that reached furthest in.
|
||||
const rulesFor = selector => {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "gm");
|
||||
const bodies = [];
|
||||
let found;
|
||||
while ((found = re.exec(CSS)) !== null) bodies.push(found[2]);
|
||||
return bodies;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the seat under the dealer button is still a seat", () => {
|
||||
test("the chip has no invisible pad reaching back over its own puck", () => {
|
||||
// Not "a smaller pad". A fixed pad cannot be safe at every puck size: the seat
|
||||
// search hands out pucks from 48px wide to 100-odd depending on the screen and
|
||||
// the number of chairs, and any pad big enough to matter on the large one
|
||||
// covers the middle of the small one.
|
||||
assert.equal(rulesFor(".dealerChip::after").length, 0,
|
||||
"the dealer chip grew a tap pad again — see the note above .dealerChip in holdem.css");
|
||||
});
|
||||
|
||||
test("the chip that has the button takes no taps", () => {
|
||||
const bodies = rulesFor(".dealerChip.active");
|
||||
assert.ok(bodies.length > 0, ".dealerChip.active has no rule at all");
|
||||
assert.ok(bodies.some(body => /pointer-events:\s*none/.test(body)),
|
||||
"the seat holding the button has a live control sitting on it");
|
||||
});
|
||||
|
||||
test("the other chips stay inert until a pointer that can hover arrives", () => {
|
||||
// This is what makes the pad unnecessary rather than merely unwanted. On touch
|
||||
// there is no hover, so no ghost chip is reachable by a finger and the only live
|
||||
// chip is the inert one above. Nothing a thumb can press is 22px.
|
||||
const ghost = rulesFor(".dealerChip:not(.active)");
|
||||
assert.ok(ghost.some(body => /pointer-events:\s*none/.test(body)),
|
||||
"a chip nobody can see is taking taps");
|
||||
|
||||
const hover = CSS.slice(CSS.indexOf("@media (hover: hover)"));
|
||||
const revealed = hover.match(/\.seat:hover\s+\.dealerChip:not\(\.active\)\s*\{([^}]*)\}/);
|
||||
assert.ok(revealed, "hovering a seat no longer offers to put the button there");
|
||||
assert.match(revealed[1], /pointer-events:\s*auto/,
|
||||
"the mouse path to moving the button is gone");
|
||||
});
|
||||
|
||||
test("and the handler does not swallow a tap it has no answer for", () => {
|
||||
// stopPropagation is what turned a pointless control into a hole. It still runs
|
||||
// for the eight seats the button could move to; the ninth returns first.
|
||||
const handler = RENDER.slice(RENDER.indexOf('querySelector(".dealerChip").addEventListener'));
|
||||
const body = handler.slice(0, handler.indexOf("});"));
|
||||
const guard = body.indexOf("dealerSeatIndex");
|
||||
const stop = body.indexOf("stopPropagation");
|
||||
assert.ok(guard >= 0, "the chip's click handler does not check whose button it is");
|
||||
assert.ok(stop >= 0, "the chip stopped guarding the seat behind it from the other chips");
|
||||
assert.ok(guard < stop,
|
||||
"the guard has to come first, or the tap is eaten before anyone decides it meant nothing");
|
||||
});
|
||||
|
||||
test("moving the button to where it already is is not a move", () => {
|
||||
// The keyboard can still reach the chip, and the same no-op would take the
|
||||
// blinds off the felt and re-post the same chips to announce that nothing
|
||||
// happened. The seat menu has always hidden "Give the button" on the seat that
|
||||
// holds it; this is the same rule where the money is.
|
||||
const from = MAIN.indexOf("makeDealer: index =>");
|
||||
assert.ok(from >= 0, "makeDealer is gone or renamed");
|
||||
const body = MAIN.slice(from, MAIN.indexOf("blindsArePosted", from));
|
||||
assert.match(body, /if\s*\(\s*index\s*===\s*state\.dealerSeatIndex\s*\)\s*return/,
|
||||
"makeDealer will re-post the blinds to move the button nowhere");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sweep can see this kind of failure now", () => {
|
||||
test("a control inside a seat counts as a rival for the seat's tap", () => {
|
||||
// The layout sweep already had a check named "a seat's centre belongs to that
|
||||
// seat", written after the same chip's pad grew *outward* and started taking
|
||||
// taps from the seat next door. It ran clean through all of this: it waved
|
||||
// through anything the seat contained, and the chip is inside the seat. The
|
||||
// check that existed for this exact bug could not see the second half of it.
|
||||
const from = AUDIT.indexOf("seatOwnership()");
|
||||
assert.ok(from >= 0, "the seat-ownership check is gone");
|
||||
const body = AUDIT.slice(from, AUDIT.indexOf("oneDealerButton()", from));
|
||||
|
||||
assert.match(body, /seat\.contains\(rival\)/,
|
||||
"a seat's own control is being waved through again");
|
||||
assert.doesNotMatch(body, /if\s*\(\s*!hit\s*\|\|\s*seat\.contains\(hit\)/,
|
||||
"the early return that hid this is back: it exempts every descendant, chip included");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
// 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");
|
||||
|
||||
@@ -52,68 +51,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
+3
-44
@@ -652,23 +652,6 @@ 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 = [];
|
||||
@@ -897,14 +880,6 @@ const LayoutAudit = {
|
||||
//
|
||||
// Reported separately from occlusion because the cause is different: not an
|
||||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||
//
|
||||
// It then made the same mistake one level down. `seat.contains(hit)` waved through
|
||||
// anything inside the seat, and the dealer chip is inside the seat: when its hit
|
||||
// pad was moved *inward* to stop it reaching the neighbours, it covered its own
|
||||
// seat's middle instead and this check called that fine. The chip's handler calls
|
||||
// stopPropagation, so those taps reached nothing — on a fresh table the button is
|
||||
// on seat 1, and seating the first player was the one thing on the felt that did
|
||||
// not work. A control inside the seat is a rival for the tap, not part of the seat.
|
||||
seatOwnership() {
|
||||
// Seats behind an open sheet aren't fighting each other for taps — nothing is
|
||||
// meant to reach them at all. Asking the question there produced six offenders
|
||||
@@ -924,29 +899,13 @@ const LayoutAudit = {
|
||||
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return;
|
||||
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit) return;
|
||||
|
||||
// The nearest control at that point, if any. A control is a rival for the
|
||||
// tap wherever it lives — the seat's own chip swallows the event as surely
|
||||
// as the seat next door's does.
|
||||
const CONTROLS = "button, a, input, select, textarea, [role='button']";
|
||||
const rival = hit.closest ? hit.closest(CONTROLS) : null;
|
||||
|
||||
if (rival && seat.contains(rival)) {
|
||||
offenders.push(
|
||||
`seat ${index + 1}'s centre goes to its own ${this.describe(rival)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Its own name, stack or felt. That is the seat, and a tap there is a tap
|
||||
// on the seat.
|
||||
if (!rival && (hit === seat || seat.contains(hit))) return;
|
||||
if (!hit || seat.contains(hit) || hit === seat) return;
|
||||
|
||||
const other = hit.closest(".seat");
|
||||
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
|
||||
const control = hit.matches && hit.matches("button, a, input, select, [role='button']");
|
||||
offenders.push(
|
||||
`seat ${index + 1}'s centre goes to ${who}${rival ? "'s " + this.describe(rival) : ""}`
|
||||
`seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
// When the tool asks the phone to stay awake, and — the part that actually matters —
|
||||
// when it lets go again.
|
||||
//
|
||||
// A dealer's phone lies face-up on the felt all night. Left alone it dims and locks
|
||||
// between the flop and the turn, every hand, and a dozen rounds of making the next step
|
||||
// readable at a glance count for nothing behind a lock screen.
|
||||
//
|
||||
// The risk runs the other way too, and it is the one worth testing. A wake lock that is
|
||||
// taken and not dropped is a phone that burns its battery through a break, a re-buy, and
|
||||
// the twenty minutes everyone spends arguing about pizza — and the dealer has no way to
|
||||
// tell that is what is happening. So the rule is narrow: held only while betting is
|
||||
// actually open, and only while the dealer wants it.
|
||||
//
|
||||
// The browser side of this is a stand-in below rather than a real navigator, which lets
|
||||
// the tests ask the questions a real table asks: what happens when the hand ends inside
|
||||
// the request, what happens when the phone takes the lock back on its own, and what
|
||||
// happens when the browser simply says no.
|
||||
const test = require("node:test");
|
||||
const { describe, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { WakeLock, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A stand-in for navigator.wakeLock that records what was asked of it. `deny` makes
|
||||
// every request reject, the way a phone in battery saver does.
|
||||
const fakeWakeLock = ({ deny = false } = {}) => {
|
||||
const api = {
|
||||
requests: 0,
|
||||
released: 0,
|
||||
live: [],
|
||||
deny,
|
||||
wakeLock: {
|
||||
request: async () => {
|
||||
api.requests += 1;
|
||||
if (api.deny) throw new Error("denied");
|
||||
const listeners = [];
|
||||
const sentinel = {
|
||||
released: false,
|
||||
addEventListener: (name, fn) => { if (name === "release") listeners.push(fn); },
|
||||
release: async () => {
|
||||
if (sentinel.released) return;
|
||||
sentinel.released = true;
|
||||
api.released += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
// What the browser does on its own when the tab stops being visible.
|
||||
dropFromOutside: () => {
|
||||
sentinel.released = true;
|
||||
listeners.forEach(fn => fn());
|
||||
},
|
||||
};
|
||||
api.live.push(sentinel);
|
||||
return sentinel;
|
||||
},
|
||||
},
|
||||
};
|
||||
return api;
|
||||
};
|
||||
|
||||
// Settles the promises `sync()` deliberately doesn't return — it is called from
|
||||
// renderAll, which is synchronous, so the acquiring happens off to one side.
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// Node has had its own `globalThis.navigator` since v21, defined as a getter — so a
|
||||
// plain assignment to it does nothing at all and throws nothing either. Every test in
|
||||
// here that thought it had installed a fake would have been quietly measuring Node's
|
||||
// real navigator, which has no wakeLock, and passing for the wrong reason.
|
||||
const setNavigator = value => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value, configurable: true, writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
let realNavigator;
|
||||
|
||||
beforeEach(() => {
|
||||
realNavigator = globalThis.navigator;
|
||||
WakeLock.sentinel = null;
|
||||
WakeLock.wanted = false;
|
||||
WakeLock.pending = false;
|
||||
WakeLock.told = true; // The toast is DOM; it is checked in the browser.
|
||||
WakeLock.hooks = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(realNavigator);
|
||||
});
|
||||
|
||||
const useFake = api => {
|
||||
setNavigator(api);
|
||||
return api;
|
||||
};
|
||||
|
||||
// A table mid-hand, which is the only state that wants the screen.
|
||||
const dealtIn = () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
state.bettingOpen = true;
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the rule about when a table needs the screen", () => {
|
||||
test("a hand in progress does", () => {
|
||||
assert.equal(WakeLock.shouldHold(dealtIn()), true);
|
||||
});
|
||||
|
||||
test("a table between hands does not", () => {
|
||||
const state = dealtIn();
|
||||
state.bettingOpen = false;
|
||||
assert.equal(WakeLock.shouldHold(state), false);
|
||||
});
|
||||
|
||||
test("a dealer who turned it off does not, mid-hand or otherwise", () => {
|
||||
const state = dealtIn();
|
||||
state.keepScreenAwake = false;
|
||||
assert.equal(WakeLock.shouldHold(state), false);
|
||||
});
|
||||
|
||||
test("no table at all does not", () => {
|
||||
assert.equal(WakeLock.shouldHold(null), false);
|
||||
assert.equal(WakeLock.shouldHold(undefined), false);
|
||||
});
|
||||
|
||||
test("a table that has never heard of the setting still does", () => {
|
||||
// Every saved table from before this existed. The migration fills it in, but the
|
||||
// rule itself has to read a missing value as yes or the first hand after an
|
||||
// update runs without it.
|
||||
const state = dealtIn();
|
||||
delete state.keepScreenAwake;
|
||||
assert.equal(WakeLock.shouldHold(state), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking the lock and giving it back", () => {
|
||||
test("a hand starting takes one", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
WakeLock.sync(dealtIn());
|
||||
await settle();
|
||||
assert.equal(api.requests, 1);
|
||||
assert.ok(WakeLock.sentinel);
|
||||
});
|
||||
|
||||
test("the hand ending gives it back", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
state.bettingOpen = false;
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.released, 1);
|
||||
assert.equal(WakeLock.sentinel, null);
|
||||
});
|
||||
|
||||
test("turning the setting off mid-hand gives it back", async () => {
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
state.keepScreenAwake = false;
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.released, 1);
|
||||
});
|
||||
|
||||
test("a whole street of renders takes exactly one", async () => {
|
||||
// sync() runs off renderAll, which fires many times a second while a street is
|
||||
// being bet. Every one of those must be a no-op, or the tool leaks a sentinel
|
||||
// per render and the screen stays lit long after the hand is over.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
}
|
||||
assert.equal(api.requests, 1);
|
||||
assert.equal(api.released, 0);
|
||||
});
|
||||
|
||||
test("two syncs in the same tick still take exactly one", async () => {
|
||||
// The `pending` guard. Without it both calls get past the "already holding one?"
|
||||
// check, and the second sentinel is never stored — so nothing can release it and
|
||||
// the screen stays awake for the rest of the night.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(api.requests, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the awkward moments", () => {
|
||||
test("a hand that ends while the request is in flight leaves nothing held", async () => {
|
||||
// A fold can end the hand between asking for the lock and being given it. The
|
||||
// sentinel that arrives afterwards belongs to a hand that no longer exists.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
|
||||
state.bettingOpen = false;
|
||||
WakeLock.sync(state);
|
||||
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
assert.equal(WakeLock.sentinel, null, "nothing should still be held");
|
||||
assert.equal(api.released, 1, "the one that arrived late should have been let go");
|
||||
});
|
||||
|
||||
test("the browser taking it back leaves us able to take it again", async () => {
|
||||
// What happens every time the dealer switches apps to check a rule. The browser
|
||||
// releases the lock itself and never gives it back; if we go on believing we
|
||||
// hold one, the screen sleeps for the rest of the night.
|
||||
const api = useFake(fakeWakeLock());
|
||||
const state = dealtIn();
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
|
||||
api.live[0].dropFromOutside();
|
||||
assert.equal(WakeLock.sentinel, null, "the release listener should have cleared it");
|
||||
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
assert.equal(api.requests, 2, "coming back to the tool should take a fresh one");
|
||||
});
|
||||
|
||||
test("a browser that says no is not an error", async () => {
|
||||
const api = useFake(fakeWakeLock({ deny: true }));
|
||||
const state = dealtIn();
|
||||
await assert.doesNotReject(async () => {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
});
|
||||
assert.equal(api.requests, 1);
|
||||
assert.equal(WakeLock.sentinel, null);
|
||||
});
|
||||
|
||||
test("a browser without the API is left alone entirely", async () => {
|
||||
useFake({});
|
||||
assert.equal(WakeLock.supported(), false);
|
||||
const state = dealtIn();
|
||||
await assert.doesNotReject(async () => {
|
||||
WakeLock.sync(state);
|
||||
await settle();
|
||||
});
|
||||
assert.equal(WakeLock.wanted, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a saved table remembers about the screen", () => {
|
||||
test("a new table keeps the screen on", () => {
|
||||
assert.equal(HoldemState.createDefault().keepScreenAwake, true);
|
||||
});
|
||||
|
||||
test("a table saved before the setting existed keeps the screen on", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
delete saved.keepScreenAwake;
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
|
||||
});
|
||||
|
||||
test("a dealer who turned it off still has it off after a reload", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
saved.keepScreenAwake = false;
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, false);
|
||||
});
|
||||
|
||||
test("junk in that field is read as on rather than trusted", () => {
|
||||
const saved = HoldemState.createDefault();
|
||||
saved.keepScreenAwake = "yes please";
|
||||
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user